Sync config from arch
- hypr/apps.lua - hypr/autostart.lua - hypr/envs.lua - hypr/hyprland.lua - hypr/hyprsunset.conf - hypr/input.lua - hypr/looknfeel.lua - hypr/omasettings.lua - hypr/xdph.conf - omarchy/branding/about.txt - omarchy/branding/screensaver.txt - omarchy/extensions/omarchy-menu.jsonc - omarchy/hooks/battery-low.d/play-warning-sound.sample - omarchy/hooks/font-set.d/show-font-notification.sample - omarchy/hooks/post-boot.d/weather.sample - omarchy/hooks/post-update.d/install-voxtype.hook - omarchy/hooks/post-update.d/setup-agent.hook - omarchy/hooks/post-update.d/setup-fingerprint.hook - omarchy/hooks/post-update.d/show-update-notification.sample - omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample - omarchy/hooks/theme-set.d/show-theme-notification.sample - omarchy/shell.json - omarchy/shell.toml - omarchy/theme.name - omarchy/themes/azure-glow/README.md - omarchy/themes/azure-glow/alacritty.toml - omarchy/themes/azure-glow/btop.theme - omarchy/themes/azure-glow/hyprland.conf - omarchy/themes/azure-glow/hyprlock.conf - omarchy/themes/azure-glow/icons.theme - … 269 more
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# A stand-in for the Bitwarden CLI, used only to capture screenshots.
|
||||
#
|
||||
# The plugin resolves `bw` from PATH, so putting this earlier on PATH points it
|
||||
# at a fixture vault instead of a real one. Nothing here talks to Bitwarden,
|
||||
# reads a keyring, or touches the network -- which is the point: the README
|
||||
# screenshots can show a populated vault without showing anyone's credentials.
|
||||
set -uo pipefail
|
||||
|
||||
FIXTURES="$(dirname "$(readlink -f "$0")")/../fixtures.json"
|
||||
j() { python3 -c "
|
||||
import json,sys
|
||||
d=json.load(open('$FIXTURES'))
|
||||
sys.stdout.write(json.dumps(d[sys.argv[1]]))" "$1"; }
|
||||
|
||||
# The vault state the fixture reports. `unlocked` for the populated shots;
|
||||
# `unauthenticated` drives the login screen, `locked` the unlock screen.
|
||||
DEMO_STATUS="${QSBW_DEMO_STATUS:-unlocked}"
|
||||
|
||||
case "${1:-}" in
|
||||
--version) echo "2026.2.0-demo" ;;
|
||||
status) python3 -c "
|
||||
import json,sys
|
||||
d=json.load(open('$FIXTURES'))
|
||||
st=dict(d['status']); st['status']=sys.argv[1]
|
||||
if sys.argv[1]=='unauthenticated':
|
||||
st['userEmail']=''; st['userId']=''
|
||||
sys.stdout.write(json.dumps(st))" "$DEMO_STATUS" ;;
|
||||
# The new generator reaches for `bw serve` first. Exiting immediately is the
|
||||
# bind-failure path, which is exactly the fallback we want exercised here.
|
||||
serve) exit 1 ;;
|
||||
sync) echo "Syncing complete." ;;
|
||||
lock) echo "Your vault is locked." ;;
|
||||
unlock) echo "demo-session-token-not-real" ;;
|
||||
generate)
|
||||
# Roughly honour --passphrase so the generator screenshot looks right.
|
||||
if [[ " $* " == *" --passphrase "* ]]; then echo "Correct-Horse-Battery-Staple"
|
||||
else echo "Xq7X2mFk9TbW4e"; fi ;;
|
||||
list)
|
||||
case "${2:-}" in
|
||||
items) j items ;;
|
||||
folders) j folders ;;
|
||||
organizations) j organizations ;;
|
||||
*) echo "[]" ;;
|
||||
esac ;;
|
||||
get)
|
||||
case "${2:-}" in
|
||||
totp) echo "418 623" | tr -d ' ' ;;
|
||||
password) echo "placeholder" ;;
|
||||
# Attachment bytes never come from the fixture file -- the panel only
|
||||
# needs a file to appear where it asked for one.
|
||||
attachment)
|
||||
out=""
|
||||
while [ $# -gt 0 ]; do
|
||||
if [ "$1" = "--output" ]; then out="${2:-}"; fi
|
||||
shift
|
||||
done
|
||||
[ -n "$out" ] || exit 1
|
||||
printf 'placeholder attachment, not real vault data\n' > "$out"
|
||||
echo "Saved $out" ;;
|
||||
item) python3 -c "
|
||||
import json,sys
|
||||
d=json.load(open('$FIXTURES'))
|
||||
want=sys.argv[1]
|
||||
for it in d['items']:
|
||||
if it['id']==want: print(json.dumps(it)); break
|
||||
else: print(json.dumps(d['items'][0]))" "${3:-i1}" ;;
|
||||
*) echo "{}" ;;
|
||||
esac ;;
|
||||
send)
|
||||
case "${2:-}" in
|
||||
list) j sends ;;
|
||||
create) echo '{"object":"send","id":"s3","name":"New Send","type":0,"accessUrl":"https://vault.bitwarden.com/#/send/demo3","accessCount":0,"maxAccessCount":null,"deletionDate":"2026-08-28T10:00:00.000Z","passwordSet":false,"disabled":false,"text":{"text":"placeholder","hidden":false}}' ;;
|
||||
delete) echo "Send deleted." ;;
|
||||
*) echo "[]" ;;
|
||||
esac ;;
|
||||
encode) cat ;;
|
||||
create|edit|delete) echo '{"object":"item","id":"new"}' ;;
|
||||
*) echo "{}" ;;
|
||||
esac
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# A stand-in for libsecret's secret-tool, used only to capture screenshots.
|
||||
#
|
||||
# Without this the fixture shell talks to the real OS keyring: it would read
|
||||
# the operator's actual Bitwarden session into the demo panel, and the shots
|
||||
# would depend on whether that entry happened to exist. Neither belongs in a
|
||||
# screenshot harness, so the keyring is faked too.
|
||||
#
|
||||
# A session lookup succeeds with an obvious placeholder -- the panel needs a
|
||||
# non-empty session before it will load any items -- and every other lookup
|
||||
# reports "not stored", so PIN and fingerprint unlock show as unconfigured.
|
||||
#
|
||||
# The placeholder carries the running boot id, because that is the shape the
|
||||
# panel now demands of a remembered session: a token from another boot is
|
||||
# refused and cleared. Without the prefix the fixture shell would come up on
|
||||
# the lock screen and there would be nothing to photograph.
|
||||
set -uo pipefail
|
||||
|
||||
account=""
|
||||
prev=""
|
||||
for arg in "$@"; do
|
||||
[[ "$prev" == "account" ]] && account="$arg"
|
||||
prev="$arg"
|
||||
done
|
||||
|
||||
case "${1:-}" in
|
||||
lookup)
|
||||
if [[ "$account" == "session" ]]; then
|
||||
echo "$(cat /proc/sys/kernel/random/boot_id) demo-session-token-not-real"
|
||||
exit 0
|
||||
fi
|
||||
exit 1 ;;
|
||||
store) cat >/dev/null; exit 0 ;;
|
||||
clear) exit 0 ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env bash
|
||||
# Capture README screenshots against a fixture vault.
|
||||
#
|
||||
# Restarts the Omarchy shell with demo/bin ahead of it on PATH, so the plugin
|
||||
# resolves `bw` to the shim and shows made-up data. Your real vault is never
|
||||
# read, and the shell is restored on the way out -- including if this script
|
||||
# is interrupted.
|
||||
#
|
||||
# ./demo/capture.sh [output-dir] (default: docs/screenshots)
|
||||
set -euo pipefail
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OUT="${1:-$REPO/docs/screenshots}"
|
||||
IPC=(qs -p /usr/share/omarchy/shell/shell.qml ipc call io.github.elevate08.qs-bitwarden-cli)
|
||||
|
||||
for tool in grim magick wtype hyprctl quickshell /usr/bin/python3; do
|
||||
command -v "$tool" >/dev/null || { echo "missing required tool: $tool" >&2; exit 1; }
|
||||
done
|
||||
|
||||
mkdir -p "$OUT"
|
||||
|
||||
restore() {
|
||||
echo "restoring the real shell..."
|
||||
# The fixture vault's SSH key gets projected to the same directory the real
|
||||
# one uses. The real shell rewrites its own keys on the next load but will
|
||||
# not remove a file it never wrote, so a demo key would sit there for good.
|
||||
rm -f "${XDG_DATA_HOME:-$HOME/.local/share}/qs-bitwarden-cli/ssh/Demo Deploy Key.pub"
|
||||
pkill -f "quickshell -n -p /usr/share/omarchy/shell" 2>/dev/null || true
|
||||
sleep 1
|
||||
omarchy restart shell >/dev/null 2>&1 || true
|
||||
}
|
||||
trap restore EXIT INT TERM
|
||||
|
||||
# start_shell <vault-state>
|
||||
#
|
||||
# The fixture shell is restarted per vault state rather than driven between
|
||||
# them: the panel reads `bw status` once on open, so the logged-out screen
|
||||
# cannot be reached from a running unlocked instance.
|
||||
start_shell() {
|
||||
echo "starting shell with the fixture vault ($1)..."
|
||||
pkill -f "quickshell -n -p /usr/share/omarchy/shell" 2>/dev/null || true
|
||||
sleep 1
|
||||
PATH="$REPO/demo/bin:$PATH" QSBW_DEMO_STATUS="$1" \
|
||||
nohup quickshell -n -p /usr/share/omarchy/shell >/dev/null 2>&1 &
|
||||
sleep 6
|
||||
# Park the pointer so hover states and tooltips stay out of the shots.
|
||||
hyprctl dispatch movecursor 100 100 >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# Crop to the panel itself rather than a fixed box. The panel resizes with its
|
||||
# content, and -- more importantly -- a loose crop would put whatever is behind
|
||||
# it (windows, filenames, terminal scrollback) into the published image.
|
||||
shot() { # shot <name>
|
||||
sleep 1
|
||||
grim "$OUT/.raw.png"
|
||||
local box err
|
||||
# Keep the locator's own reason. Swallowing it turned a theme change into
|
||||
# six identical "could not locate the panel border" lines and no clue why.
|
||||
if box="$(/usr/bin/python3 "$REPO/demo/find_panel.py" "$OUT/.raw.png" 2>/tmp/find_panel.err)"; then
|
||||
magick "$OUT/.raw.png" -crop "$box" +repage "$OUT/$1.png"
|
||||
echo " wrote $1.png ($box)"
|
||||
else
|
||||
err="$(cat /tmp/find_panel.err)"
|
||||
echo " SKIPPED $1: ${err:-could not locate the panel border}" >&2
|
||||
fi
|
||||
rm -f /tmp/find_panel.err
|
||||
if [ -n "${QSBW_KEEP_RAW:-}" ]; then mv -f "$OUT/.raw.png" "$OUT/.raw-$1.png" 2>/dev/null || true
|
||||
else rm -f "$OUT/.raw.png"; fi
|
||||
}
|
||||
|
||||
# open_detail <search text>
|
||||
#
|
||||
# Narrows the list to one item and opens it. Typing the name is steadier than
|
||||
# counting Down presses: the list is sorted favourites-first and then by name,
|
||||
# so an added fixture would silently shift every offset.
|
||||
#
|
||||
# The Down is what hands focus back from the search field to the key catcher --
|
||||
# with one result it clamps to the only row -- and `e` on the list opens the
|
||||
# detail. (`e` again, on the detail, opens the form.)
|
||||
open_detail() {
|
||||
wtype "/" 2>/dev/null; sleep 1
|
||||
wtype "$1" 2>/dev/null; sleep 2
|
||||
wtype -k Down 2>/dev/null; sleep 1
|
||||
wtype "e" 2>/dev/null; sleep 2
|
||||
}
|
||||
|
||||
# Back to an empty list from wherever open_detail left us.
|
||||
clear_search() {
|
||||
wtype -k Escape 2>/dev/null; sleep 1
|
||||
wtype "/" 2>/dev/null; sleep 1
|
||||
wtype -M ctrl -k a -m ctrl 2>/dev/null
|
||||
wtype -k BackSpace 2>/dev/null; sleep 1
|
||||
wtype -k Down 2>/dev/null; sleep 1
|
||||
}
|
||||
|
||||
# --- SSH signing approval ---------------------------------------------------
|
||||
#
|
||||
# The approval screen exists only while a real signing request is waiting, so
|
||||
# it cannot be navigated to -- it has to be raised. `ssh-add -T` asks the agent
|
||||
# to sign one challenge with one key and nothing else, which is the smallest
|
||||
# request that produces this prompt.
|
||||
#
|
||||
# Everything here is the fixture vault: the key is the throwaway pair in
|
||||
# fixtures.json, and the socket belongs to the fixture shell started above.
|
||||
# The request is denied rather than approved, so no signature is ever made.
|
||||
capture_ssh_approval() {
|
||||
local sock="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/qs-bitwarden-cli/ssh-agent.sock"
|
||||
local pub="${XDG_DATA_HOME:-$HOME/.local/share}/qs-bitwarden-cli/ssh/Demo Deploy Key.pub"
|
||||
|
||||
# The helper starts with the panel and projects its public keys after the
|
||||
# vault loads, so wait for the file rather than guessing at a delay.
|
||||
local waited=0
|
||||
while [ ! -S "$sock" ] || [ ! -f "$pub" ]; do
|
||||
sleep 1; waited=$((waited + 1))
|
||||
if [ "$waited" -ge 30 ]; then
|
||||
echo " skipped 13-ssh-approval: no agent socket or projected key after ${waited}s" >&2
|
||||
echo " (is 'Act as your SSH agent' enabled in shell.json?)" >&2
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
"${IPC[@]}" open >/dev/null 2>&1; sleep 2
|
||||
# In the background: it blocks until the prompt is answered, which is the
|
||||
# point -- the prompt has to still be on screen when the shot is taken.
|
||||
SSH_AUTH_SOCK="$sock" ssh-add -T "$pub" >/dev/null 2>&1 &
|
||||
local asker=$!
|
||||
sleep "${QSBW_SSH_SETTLE:-4}"
|
||||
shot 13-ssh-approval
|
||||
# Deny it. Escape is the approval screen's own deny, so nothing is signed.
|
||||
wtype -k Escape 2>/dev/null; sleep 1
|
||||
wait "$asker" 2>/dev/null || true
|
||||
"${IPC[@]}" close >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# QSBW_ONLY_SSH=1 captures the approval shot alone. It is the only shot that
|
||||
# depends on timing rather than on a keystroke, so it is the one that gets
|
||||
# iterated on, and re-running the whole sequence to retake it costs a minute
|
||||
# and five shells.
|
||||
if [ -n "${QSBW_ONLY_SSH:-}" ]; then
|
||||
start_shell unlocked
|
||||
capture_ssh_approval
|
||||
echo "done -> $OUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- logged out -------------------------------------------------------------
|
||||
|
||||
# No setup shot. The wizard appears only while a required tool is missing, and
|
||||
# it watches for the install and moves on by itself the moment one lands. The
|
||||
# fixture environment has every dependency -- that is what makes the rest of
|
||||
# these shots work -- so the screen correctly advances straight past itself.
|
||||
# Photographing it means deliberately hiding `jq` or `bw` from the shell's
|
||||
# PATH, which breaks the vault reads every other shot depends on.
|
||||
start_shell unauthenticated
|
||||
"${IPC[@]}" open >/dev/null 2>&1; sleep 4
|
||||
shot 12-login
|
||||
"${IPC[@]}" close >/dev/null 2>&1 || true
|
||||
|
||||
# --- locked -----------------------------------------------------------------
|
||||
|
||||
start_shell locked
|
||||
"${IPC[@]}" open >/dev/null 2>&1; sleep 4
|
||||
shot 11-locked
|
||||
"${IPC[@]}" close >/dev/null 2>&1 || true
|
||||
|
||||
# --- unlocked, populated vault ----------------------------------------------
|
||||
|
||||
start_shell unlocked
|
||||
"${IPC[@]}" open >/dev/null 2>&1; sleep 4
|
||||
shot 01-vault-list
|
||||
|
||||
# One of each item type the panel draws differently. A login has credentials
|
||||
# and a TOTP; a card and an identity have the field blocks added in 1.7.0, and
|
||||
# an identity is the one that shows the address as a single copyable block.
|
||||
open_detail "GitHub"
|
||||
shot 02-login-detail
|
||||
wtype "e" 2>/dev/null; sleep 2
|
||||
shot 03-edit-item
|
||||
wtype -k Escape 2>/dev/null; sleep 1
|
||||
clear_search
|
||||
|
||||
open_detail "Demo Card"
|
||||
shot 04-card-detail
|
||||
clear_search
|
||||
|
||||
open_detail "Dana Demo"
|
||||
shot 05-identity-detail
|
||||
clear_search
|
||||
|
||||
wtype "f" 2>/dev/null; sleep 2
|
||||
shot 06-folder-drawer
|
||||
wtype -k Escape 2>/dev/null; sleep 1
|
||||
|
||||
wtype "t" 2>/dev/null; sleep 2
|
||||
shot 07-type-filter
|
||||
wtype -k Escape 2>/dev/null; sleep 1
|
||||
|
||||
wtype "g" 2>/dev/null; sleep 5
|
||||
shot 08-generator
|
||||
wtype -k Escape 2>/dev/null; sleep 1
|
||||
|
||||
wtype -M alt -k s -m alt 2>/dev/null; sleep 3
|
||||
shot 09-sends
|
||||
wtype -k Escape 2>/dev/null; sleep 1
|
||||
|
||||
wtype -M alt -k comma -m alt 2>/dev/null; sleep 3
|
||||
shot 10-settings
|
||||
|
||||
"${IPC[@]}" close >/dev/null 2>&1 || true
|
||||
|
||||
capture_ssh_approval
|
||||
|
||||
echo "done -> $OUT"
|
||||
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env bash
|
||||
# Compose preview.png from the captured screenshots.
|
||||
#
|
||||
# The preview used to be assembled by hand, which meant adding a panel to it
|
||||
# was an image-editing job and nobody could tell which screenshots a given
|
||||
# preview.png was built from. This script is the recipe: run demo/capture.sh
|
||||
# first, then this.
|
||||
#
|
||||
# ./demo/compose-preview.sh [screenshot-dir] [output]
|
||||
# ./demo/compose-preview.sh --badge-only (reuse the cached base)
|
||||
#
|
||||
# The base -- panels and title, everything that comes from screenshots -- is
|
||||
# cached beside the shots. Only the callout changes when a release wants a
|
||||
# different phrase, and rebuilding six panels to redraw one banner made trying
|
||||
# wordings slower than it needed to be.
|
||||
#
|
||||
# Everything it reads is fixture data by construction -- capture.sh only ever
|
||||
# runs against demo/bin/bw and demo/fixtures.json -- so nothing here can put a
|
||||
# real vault into a published image.
|
||||
set -euo pipefail
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
BADGE_ONLY=0
|
||||
args=()
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--badge-only) BADGE_ONLY=1 ;;
|
||||
*) args+=("$a") ;;
|
||||
esac
|
||||
done
|
||||
SHOTS="${args[0]:-$REPO/docs/screenshots}"
|
||||
OUT="${args[1]:-$REPO/preview.png}"
|
||||
BASE="$SHOTS/.preview-base.png"
|
||||
|
||||
command -v magick >/dev/null || { echo "missing required tool: magick" >&2; exit 1; }
|
||||
|
||||
# Sampled from the preview this replaces, so the rebuild is the same design
|
||||
# rather than an approximation of it.
|
||||
BG='#060400'
|
||||
ACCENT='#F2A502'
|
||||
TITLE='Bitwarden Vault Plugin'
|
||||
# ImageMagick's own name for the face, which is not the fontconfig family
|
||||
# name. `magick -list font` is the list it will accept; override if your
|
||||
# machine names it differently.
|
||||
FONT="${QSBW_PREVIEW_FONT:-CaskaydiaMono-NF-Bold}"
|
||||
FONT_BODY="${QSBW_PREVIEW_FONT_BODY:-CaskaydiaMono-NF-Regular}"
|
||||
|
||||
# The callout that fills the empty corner under the first column. It is the
|
||||
# one element here that is not a screenshot, so it borrows the panels' own
|
||||
# vocabulary -- same accent, same square border, same black ground -- and
|
||||
# earns its place by naming what the release added.
|
||||
# Set either from the environment to try a phrase without touching the file:
|
||||
# QSBW_BADGE_TITLE='SSH Agent Support' ./demo/compose-preview.sh --badge-only
|
||||
BADGE_KICKER="${QSBW_BADGE_KICKER:-NEW}"
|
||||
BADGE_TITLE="${QSBW_BADGE_TITLE:-Cards & Identities}"
|
||||
# Filled with the accent and lettered in the page's own black, rather than
|
||||
# outlined like the panels. A seventh accent-bordered rectangle read as one
|
||||
# more screenshot; this cannot be mistaken for one.
|
||||
BADGE_FG="$BG"
|
||||
BADGE_BG="$ACCENT"
|
||||
# Width kept clear at the right of the title band for the callout. The page
|
||||
# title is centred in what is left rather than in the whole width, so the gap
|
||||
# between the two does not depend on how long the badge phrase happens to be
|
||||
# -- and a badge that outgrows this is told to shrink rather than silently
|
||||
# shunting into the title.
|
||||
BADGE_ZONE=640
|
||||
GAP=28 # between panels, and around the whole thing
|
||||
TITLE_SIZE=96
|
||||
|
||||
# Three columns, top to bottom. The vault list carries the folder drawer
|
||||
# because that shot shows both at once; the plain list would be redundant
|
||||
# beside it.
|
||||
# A selection, not the whole set. capture.sh takes a shot of every screen so
|
||||
# the documentation has one; this picks the handful that say what the plugin
|
||||
# is at a glance, and leaves out the ones that look like every other panel's
|
||||
# equivalent (login, setup, locked, the filter drawers).
|
||||
#
|
||||
# Grouped to keep the three columns near the same height -- the page is as tall
|
||||
# as its tallest column, and an unbalanced one leaves a corner of empty black.
|
||||
COL1=(01-vault-list 08-generator)
|
||||
COL2=(04-card-detail 09-sends)
|
||||
COL3=(10-settings 13-ssh-approval)
|
||||
|
||||
# Not `magick ... | grep -q`: grep exits on the first match, magick takes a
|
||||
# SIGPIPE for it, and `set -o pipefail` reports the successful match as a
|
||||
# failed pipeline.
|
||||
grep -qx " Font: $FONT" <<<"$(magick -list font)" || {
|
||||
echo "magick does not know the font '$FONT'." >&2
|
||||
echo "Pick one from \`magick -list font\` and set QSBW_PREVIEW_FONT." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
column() { # column <name>...
|
||||
local files=() f
|
||||
for f in "$@"; do
|
||||
if [ -f "$SHOTS/$f.png" ]; then files+=("$SHOTS/$f.png")
|
||||
else echo " missing $f.png, leaving it out" >&2; fi
|
||||
done
|
||||
[ ${#files[@]} -gt 0 ] || { echo "no screenshots for a column" >&2; exit 1; }
|
||||
# -background so the gap between stacked panels is the page colour, not white.
|
||||
magick "${files[@]}" -background "$BG" -gravity north -splice "0x${GAP}" \
|
||||
-append -chop "0x${GAP}" miff:-
|
||||
}
|
||||
|
||||
work="$(mktemp -d)"; trap 'rm -rf "$work"' EXIT
|
||||
|
||||
if [ "$BADGE_ONLY" = 1 ]; then
|
||||
[ -f "$BASE" ] || { echo "no cached base at $BASE; run without --badge-only first" >&2; exit 1; }
|
||||
cp "$BASE" "$work/page.png"
|
||||
else
|
||||
column "${COL1[@]}" > "$work/c1.miff"
|
||||
column "${COL2[@]}" > "$work/c2.miff"
|
||||
column "${COL3[@]}" > "$work/c3.miff"
|
||||
|
||||
# Columns side by side, each hung from the top. The gravity has to be north
|
||||
# for the append itself: +append centres shorter images vertically unless told
|
||||
# otherwise, which left the third column floating in the middle of the page.
|
||||
magick "$work/c1.miff" "$work/c2.miff" "$work/c3.miff" \
|
||||
-background "$BG" -gravity west -splice "${GAP}x0" \
|
||||
-gravity north +append -chop "${GAP}x0" "$work/panels.png"
|
||||
|
||||
magick "$work/panels.png" \
|
||||
-background "$BG" \
|
||||
-gravity south -splice "0x${GAP}" \
|
||||
-gravity west -splice "${GAP}x0" \
|
||||
-gravity east -splice "${GAP}x0" \
|
||||
"$work/framed.png"
|
||||
|
||||
# The title as its own image, the width of the page, rather than annotated on
|
||||
# to it. `-annotate` with a gravity places from an origin this composition
|
||||
# keeps moving, and the text ended up off the left edge; a label of a known
|
||||
# size cannot land anywhere but where it is appended.
|
||||
WIDTH="$(magick identify -format '%w' "$work/framed.png")"
|
||||
magick -background "$BG" -fill "$ACCENT" -font "$FONT" \
|
||||
-pointsize "$TITLE_SIZE" label:"$TITLE" "$work/title-text.png"
|
||||
magick "$work/title-text.png" -background "$BG" -gravity center \
|
||||
-extent "$((WIDTH - BADGE_ZONE))x$((TITLE_SIZE * 2))" \
|
||||
-gravity east -splice "${BADGE_ZONE}x0" "$work/title.png"
|
||||
|
||||
# -depth 8: the label pushes the pipeline to 16-bit, which triples the file
|
||||
# size of a flat-colour image for nothing a viewer can see.
|
||||
magick "$work/title.png" "$work/framed.png" -background "$BG" -append \
|
||||
"$work/page.png"
|
||||
|
||||
cp "$work/page.png" "$BASE"
|
||||
# The callout straddles the bottom of the title band, which a badge-only run
|
||||
# has no way to measure -- the pieces are gone by then. Record it.
|
||||
magick identify -format '%h' "$work/title.png" > "$BASE.anchor"
|
||||
fi
|
||||
[ -f "$BASE.anchor" ] || { echo "no anchor beside $BASE; rebuild the base" >&2; exit 1; }
|
||||
TITLE_H="$(cat "$BASE.anchor")"
|
||||
|
||||
# --- the callout ------------------------------------------------------------
|
||||
#
|
||||
# Drawn as its own image and composited, rather than annotated on to the page:
|
||||
# it overlaps the panel above it by design, and a composite is the only way to
|
||||
# put something over a region that already has pixels in it.
|
||||
# No -size here: `label:` with an explicit size scales the text to fill it,
|
||||
# which turned a 34pt kicker into a 500pt "NEW" across the whole badge. The
|
||||
# point size governs, and the padding is added afterwards.
|
||||
magick -background "$BADGE_BG" -fill "$BADGE_FG" \
|
||||
-font "$FONT" -pointsize 26 -interword-spacing 10 \
|
||||
label:"$BADGE_KICKER" "$work/kicker.png"
|
||||
magick -background "$BADGE_BG" -fill "$BADGE_FG" \
|
||||
-font "$FONT" -pointsize 44 label:"$BADGE_TITLE" "$work/badge-title.png"
|
||||
|
||||
# A solid block, not an outline: the panels are all accent-bordered rectangles
|
||||
# on black, so one more of those disappears among them. Inverting it -- accent
|
||||
# ground, black letters -- is what makes it read as a label on the image
|
||||
# rather than a part of it.
|
||||
magick "$work/kicker.png" "$work/badge-title.png" \
|
||||
-background "$BADGE_BG" -gravity west -append \
|
||||
-bordercolor "$BADGE_BG" -border 22 \
|
||||
"$work/badge.png"
|
||||
|
||||
# Top right: the title is centred, so the corner beside it is empty, and
|
||||
# hanging the badge below the band's edge lets it clip the first panel of the
|
||||
# third column -- which is what keeps it looking placed rather than laid out.
|
||||
PAGE_W="$(magick identify -format '%w' "$work/page.png")"
|
||||
BADGE_BW="$(magick identify -format '%w' "$work/badge.png")"
|
||||
BADGE_BH="$(magick identify -format '%h' "$work/badge.png")"
|
||||
BADGE_X=$((PAGE_W - BADGE_BW - GAP))
|
||||
# Centred in the title band rather than overlapping anything. The two
|
||||
# neighbours here are both load-bearing -- the page title to its left and the
|
||||
# panel header below it -- and dipping into either one cost more than the
|
||||
# overlap was worth.
|
||||
BADGE_Y=$(((TITLE_H - BADGE_BH) / 2))
|
||||
|
||||
if [ "$BADGE_BW" -gt "$((BADGE_ZONE - GAP))" ]; then
|
||||
echo " warning: the badge is wider than the ${BADGE_ZONE}px reserved for it" >&2
|
||||
echo " and will crowd the page title. Shorten the phrase or raise BADGE_ZONE." >&2
|
||||
fi
|
||||
|
||||
magick "$work/page.png" "$work/badge.png" -geometry "+${BADGE_X}+${BADGE_Y}" \
|
||||
-composite -depth 8 -strip "$OUT"
|
||||
|
||||
magick identify "$OUT"
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Locate the plugin panel in a screenshot by its accent border.
|
||||
|
||||
Trimming to "any accent-coloured pixel" is not enough: the compositor draws the
|
||||
focused window's border in the same accent, so a trim swallows whatever sits
|
||||
behind the panel. The panel is instead found as a filled rectangle -- four
|
||||
borders enclosing a region -- and the largest such rectangle is returned.
|
||||
|
||||
The accent itself is read out of the image rather than hardcoded. It is a theme
|
||||
colour, so a hardcoded value silently stops matching the day the operator
|
||||
changes themes -- which is exactly how this last failed, with every shot
|
||||
reported as "could not locate the panel border" and no hint as to why.
|
||||
|
||||
find_panel.py <image> [--accent RRGGBB] -> "WxH+X+Y" on stdout
|
||||
"""
|
||||
import sys
|
||||
from collections import Counter
|
||||
from PIL import Image
|
||||
|
||||
# Enough slack for antialiasing and the border's own gradient, not enough to
|
||||
# merge two distinct theme colours.
|
||||
TOLERANCE = 26
|
||||
# The panel is wider than any window border is thick.
|
||||
MIN_RUN = 260
|
||||
|
||||
|
||||
def close(px, target, tol=TOLERANCE):
|
||||
return all(abs(px[i] - target[i]) <= tol for i in range(3))
|
||||
|
||||
|
||||
def candidate_accents(img, limit=6):
|
||||
"""Saturated colours in the image, most common first.
|
||||
|
||||
The panel border is a solid run of one theme colour, so it is always among
|
||||
the most common saturated pixels. Returning several candidates means a
|
||||
highlighted row or a colourful wallpaper cannot derail the search.
|
||||
"""
|
||||
w, h = img.size
|
||||
px = img.load()
|
||||
counts = Counter()
|
||||
for y in range(0, h, 2):
|
||||
for x in range(0, w, 2):
|
||||
r, g, b = px[x, y]
|
||||
if max(r, g, b) > 110 and (max(r, g, b) - min(r, g, b)) > 60:
|
||||
counts[(r, g, b)] += 1
|
||||
|
||||
accents = []
|
||||
for colour, _ in counts.most_common():
|
||||
# Skip anything already covered by a candidate we kept.
|
||||
if any(close(colour, kept) for kept in accents):
|
||||
continue
|
||||
accents.append(colour)
|
||||
if len(accents) >= limit:
|
||||
break
|
||||
return accents
|
||||
|
||||
|
||||
def find_box(img, accent):
|
||||
w, h = img.size
|
||||
px = img.load()
|
||||
|
||||
# Rows that contain a long horizontal run of accent pixels are candidate
|
||||
# top/bottom borders.
|
||||
runs = {} # row -> (start, end) of its longest accent run
|
||||
for y in range(h):
|
||||
best = (0, 0, 0)
|
||||
run_start, run_len = None, 0
|
||||
for x in range(w):
|
||||
if close(px[x, y], accent):
|
||||
if run_start is None:
|
||||
run_start = x
|
||||
run_len += 1
|
||||
else:
|
||||
if run_len > best[0]:
|
||||
best = (run_len, run_start, x - 1)
|
||||
run_start, run_len = None, 0
|
||||
if run_len > best[0]:
|
||||
best = (run_len, run_start, w - 1)
|
||||
if best[0] >= MIN_RUN:
|
||||
runs[y] = (best[1], best[2])
|
||||
|
||||
if not runs:
|
||||
return None
|
||||
|
||||
# Pair each top edge with the furthest bottom edge sharing its extent, and
|
||||
# keep the tallest rectangle: that is the panel, not a window border.
|
||||
best_box = None
|
||||
ys = sorted(runs)
|
||||
for i, top in enumerate(ys):
|
||||
x0, x1 = runs[top]
|
||||
for bottom in reversed(ys[i + 1:]):
|
||||
bx0, bx1 = runs[bottom]
|
||||
if abs(bx0 - x0) <= 4 and abs(bx1 - x1) <= 4 and bottom - top > 120:
|
||||
box = (x1 - x0 + 1, bottom - top + 1, x0, top)
|
||||
if best_box is None or box[0] * box[1] > best_box[0] * best_box[1]:
|
||||
best_box = box
|
||||
break
|
||||
|
||||
return best_box
|
||||
|
||||
|
||||
def main():
|
||||
path = sys.argv[1]
|
||||
img = Image.open(path).convert("RGB")
|
||||
|
||||
if "--accent" in sys.argv:
|
||||
h = sys.argv[sys.argv.index("--accent") + 1].lstrip("#")
|
||||
accents = [tuple(int(h[i:i+2], 16) for i in (0, 2, 4))]
|
||||
else:
|
||||
accents = candidate_accents(img)
|
||||
|
||||
if not accents:
|
||||
sys.exit("no saturated colour in the image to use as an accent")
|
||||
|
||||
for accent in accents:
|
||||
box = find_box(img, accent)
|
||||
if box:
|
||||
print("%dx%d+%d+%d" % box)
|
||||
return
|
||||
|
||||
tried = ", ".join("#%02X%02X%02X" % a for a in accents)
|
||||
sys.exit("no enclosed rectangle found (tried %s)" % tried)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,290 @@
|
||||
{
|
||||
"status": {
|
||||
"serverUrl": "https://vault.bitwarden.com",
|
||||
"lastSync": "2026-08-21T10:00:00.000Z",
|
||||
"userEmail": "demo@example.com",
|
||||
"userId": "00000000-0000-0000-0000-000000000000",
|
||||
"status": "unlocked"
|
||||
},
|
||||
"folders": [
|
||||
{
|
||||
"object": "folder",
|
||||
"id": "f-infra",
|
||||
"name": "Infrastructure"
|
||||
},
|
||||
{
|
||||
"object": "folder",
|
||||
"id": "f-fin",
|
||||
"name": "Finance"
|
||||
},
|
||||
{
|
||||
"object": "folder",
|
||||
"id": "f-social",
|
||||
"name": "Social"
|
||||
}
|
||||
],
|
||||
"organizations": [
|
||||
{
|
||||
"object": "organization",
|
||||
"id": "org-acme",
|
||||
"name": "Acme Corp",
|
||||
"status": 2
|
||||
}
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"object": "item",
|
||||
"id": "i1",
|
||||
"organizationId": null,
|
||||
"folderId": "f-social",
|
||||
"type": 1,
|
||||
"name": "GitHub",
|
||||
"favorite": true,
|
||||
"login": {
|
||||
"username": "demo-user",
|
||||
"password": "hunter2-not-real",
|
||||
"totp": "otpauth://totp/demo",
|
||||
"uris": [
|
||||
{
|
||||
"uri": "https://github.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"object": "item",
|
||||
"id": "i2",
|
||||
"organizationId": null,
|
||||
"folderId": "f-social",
|
||||
"type": 1,
|
||||
"name": "Reddit",
|
||||
"favorite": false,
|
||||
"login": {
|
||||
"username": "demo-user",
|
||||
"password": "placeholder",
|
||||
"totp": null,
|
||||
"uris": [
|
||||
{
|
||||
"uri": "https://reddit.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"object": "item",
|
||||
"id": "i3",
|
||||
"organizationId": "org-acme",
|
||||
"folderId": "f-infra",
|
||||
"type": 1,
|
||||
"name": "Acme VPN",
|
||||
"favorite": false,
|
||||
"login": {
|
||||
"username": "d.demo@example.com",
|
||||
"password": "placeholder",
|
||||
"totp": "otpauth://totp/demo",
|
||||
"uris": [
|
||||
{
|
||||
"uri": "https://vpn.acme.example"
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachments": [
|
||||
{
|
||||
"object": "attachment",
|
||||
"id": "a3",
|
||||
"fileName": "acme-vpn.ovpn",
|
||||
"size": "8192",
|
||||
"sizeName": "8 KB",
|
||||
"url": "https://example.invalid/a3"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"object": "item",
|
||||
"id": "i4",
|
||||
"organizationId": null,
|
||||
"folderId": "f-infra",
|
||||
"type": 1,
|
||||
"name": "Home Assistant",
|
||||
"favorite": true,
|
||||
"login": {
|
||||
"username": "admin",
|
||||
"password": "placeholder",
|
||||
"totp": null,
|
||||
"uris": [
|
||||
{
|
||||
"uri": "https://homeassistant.local:8123"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"object": "item",
|
||||
"id": "i5",
|
||||
"organizationId": null,
|
||||
"folderId": "f-fin",
|
||||
"type": 1,
|
||||
"name": "Example Bank",
|
||||
"favorite": false,
|
||||
"login": {
|
||||
"username": "demo-user",
|
||||
"password": "placeholder",
|
||||
"totp": "otpauth://totp/demo",
|
||||
"uris": [
|
||||
{
|
||||
"uri": "https://bank.example.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"object": "item",
|
||||
"id": "i6",
|
||||
"organizationId": null,
|
||||
"folderId": null,
|
||||
"type": 2,
|
||||
"name": "Recovery Codes",
|
||||
"favorite": false,
|
||||
"notes": "Placeholder note. No real data here.",
|
||||
"secureNote": {
|
||||
"type": 0
|
||||
},
|
||||
"attachments": [
|
||||
{
|
||||
"object": "attachment",
|
||||
"id": "a1",
|
||||
"fileName": "recovery-codes.txt",
|
||||
"size": "412",
|
||||
"sizeName": "412 B",
|
||||
"url": "https://example.invalid/a1"
|
||||
},
|
||||
{
|
||||
"object": "attachment",
|
||||
"id": "a2",
|
||||
"fileName": "backup-key.pem",
|
||||
"size": "3204",
|
||||
"sizeName": "3.13 KB",
|
||||
"url": "https://example.invalid/a2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"object": "item",
|
||||
"id": "i7",
|
||||
"organizationId": null,
|
||||
"folderId": "f-fin",
|
||||
"type": 3,
|
||||
"name": "Demo Card",
|
||||
"favorite": false,
|
||||
"card": {
|
||||
"cardholderName": "D. Demo",
|
||||
"brand": "Visa",
|
||||
"number": "4111111111111111",
|
||||
"expMonth": "12",
|
||||
"expYear": "2030",
|
||||
"code": "123"
|
||||
}
|
||||
},
|
||||
{
|
||||
"object": "item",
|
||||
"id": "i8",
|
||||
"organizationId": "org-acme",
|
||||
"folderId": "f-infra",
|
||||
"type": 1,
|
||||
"name": "Acme Grafana",
|
||||
"favorite": false,
|
||||
"login": {
|
||||
"username": "d.demo@example.com",
|
||||
"password": "placeholder",
|
||||
"totp": null,
|
||||
"uris": [
|
||||
{
|
||||
"uri": "https://grafana.acme.example"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"object": "item",
|
||||
"id": "i9",
|
||||
"organizationId": null,
|
||||
"folderId": null,
|
||||
"type": 4,
|
||||
"name": "Dana Demo",
|
||||
"favorite": false,
|
||||
"identity": {
|
||||
"title": "Ms",
|
||||
"firstName": "Dana",
|
||||
"middleName": "R",
|
||||
"lastName": "Demo",
|
||||
"username": "danademo",
|
||||
"company": "Example Industries",
|
||||
"email": "demo@example.com",
|
||||
"phone": "+1 555 0100",
|
||||
"ssn": "000-00-0000",
|
||||
"passportNumber": "X1234567",
|
||||
"licenseNumber": "D-0000-0000",
|
||||
"address1": "1 Example Way",
|
||||
"address2": "Suite 200",
|
||||
"address3": "",
|
||||
"city": "Springfield",
|
||||
"state": "IL",
|
||||
"postalCode": "62701",
|
||||
"country": "US"
|
||||
}
|
||||
},
|
||||
{
|
||||
"object": "item",
|
||||
"id": "i-ssh-1",
|
||||
"organizationId": null,
|
||||
"folderId": "f-infra",
|
||||
"type": 5,
|
||||
"name": "Demo Deploy Key",
|
||||
"notes": null,
|
||||
"favorite": false,
|
||||
"collectionIds": [],
|
||||
"reprompt": 0,
|
||||
"revisionDate": "2026-08-20T10:00:00.000Z",
|
||||
"creationDate": "2026-08-20T10:00:00.000Z",
|
||||
"sshKey": {
|
||||
"privateKey": "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACBGTamxk2fLE7NZekQoaoQkLjAbzaNDSJrO8clPlNnoXAAAAJhQ/dXxUP3V\n8QAAAAtzc2gtZWQyNTUxOQAAACBGTamxk2fLE7NZekQoaoQkLjAbzaNDSJrO8clPlNnoXA\nAAAEAqEDoJ+o48bC8qt9agrYLugGkX0IjZdM/iT5kK0Q0BGUZNqbGTZ8sTs1l6RChqhCQu\nMBvNo0NIms7xyU+U2ehcAAAAEGRlbW9AZXhhbXBsZS5jb20BAgMEBQ==\n-----END OPENSSH PRIVATE KEY-----\n",
|
||||
"publicKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEZNqbGTZ8sTs1l6RChqhCQuMBvNo0NIms7xyU+U2ehc demo@example.com",
|
||||
"keyFingerprint": "SHA256:WJN+07USrkd8tFp3vVJBXAjwolWfskqqzl+UPp5GH30"
|
||||
}
|
||||
}
|
||||
],
|
||||
"sends": [
|
||||
{
|
||||
"object": "send",
|
||||
"id": "s1",
|
||||
"name": "Wifi password for guests",
|
||||
"type": 0,
|
||||
"accessUrl": "https://vault.bitwarden.com/#/send/demo1",
|
||||
"accessCount": 1,
|
||||
"maxAccessCount": 5,
|
||||
"deletionDate": "2026-08-24T10:00:00.000Z",
|
||||
"passwordSet": true,
|
||||
"disabled": false,
|
||||
"text": {
|
||||
"text": "placeholder",
|
||||
"hidden": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"object": "send",
|
||||
"id": "s2",
|
||||
"name": "Onboarding checklist",
|
||||
"type": 0,
|
||||
"accessUrl": "https://vault.bitwarden.com/#/send/demo2",
|
||||
"accessCount": 0,
|
||||
"maxAccessCount": null,
|
||||
"deletionDate": "2026-08-28T10:00:00.000Z",
|
||||
"passwordSet": false,
|
||||
"disabled": false,
|
||||
"text": {
|
||||
"text": "placeholder",
|
||||
"hidden": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user