"$fixture/README.md"
+ printf 'window.bundleFixture = true;\n' > "$fixture/ui/bundle.js"
+ for executable in \
+ plugin-linux-amd64 plugin-linux-arm64 \
+ plugin-darwin-amd64 plugin-darwin-arm64 \
+ plugin-windows-amd64.exe; do
+ printf 'fixture binary: %s\n' "$executable" > "$fixture/server/$executable"
+ done
+ write_checksums "$fixture"
+}
+
+copy_fixture() {
+ name=$1
+ mkdir -p "$test_dir/$name"
+ cp -R "$test_dir/valid/." "$test_dir/$name/"
+}
+
+expect_failure() {
+ name=$1
+ fixture=$2
+ mode=${3-full}
+ platform=${4-}
+ if [ "$mode" = host ]; then
+ if sh "$verify_script" "$fixture" host "$platform" >/dev/null 2>&1; then
+ printf 'expected package verification to reject %s\n' "$name" >&2
+ exit 1
+ fi
+ elif sh "$verify_script" "$fixture" full >/dev/null 2>&1; then
+ printf 'expected package verification to reject %s\n' "$name" >&2
+ exit 1
+ fi
+}
+
+create_fixture "$test_dir/valid"
+sh "$verify_script" "$test_dir/valid" full >/dev/null
+
+host_platform=$(go env GOOS)-$(go env GOARCH)
+copy_fixture host-valid
+host_fixture=$test_dir/host-valid
+host_executable=$(awk -v platform="$host_platform" '$0 ~ " " platform ":" { gsub(/"/, "", $2); print $2 }' "$host_fixture/manifest.yaml")
+[ -n "$host_executable" ] || { printf 'fixture does not declare host platform %s\n' "$host_platform" >&2; exit 1; }
+for executable in "$host_fixture"/server/*; do
+ [ "$executable" = "$host_fixture/$host_executable" ] || rm "$executable"
+done
+write_checksums "$host_fixture"
+sh "$verify_script" "$host_fixture" host "$host_platform" >/dev/null
+if sh "$verify_script" "$host_fixture" full >/dev/null 2>&1; then
+ printf 'expected the host-only fixture to fail full-package verification\n' >&2
+ exit 1
+fi
+
+copy_fixture missing-ui
+rm "$test_dir/missing-ui/ui/bundle.js"
+write_checksums "$test_dir/missing-ui"
+expect_failure 'a missing UI bundle' "$test_dir/missing-ui"
+
+copy_fixture missing-binary
+rm "$test_dir/missing-binary/server/plugin-linux-amd64"
+write_checksums "$test_dir/missing-binary"
+expect_failure 'a missing declared platform binary' "$test_dir/missing-binary"
+
+copy_fixture unexpected-file
+printf 'unexpected\n' > "$test_dir/unexpected-file/extra.txt"
+write_checksums "$test_dir/unexpected-file"
+expect_failure 'an unexpected package file' "$test_dir/unexpected-file"
+
+copy_fixture incomplete-checksums
+sed '/ui\/bundle.js$/d' "$test_dir/incomplete-checksums/checksums.txt" > "$test_dir/incomplete-checksums/checksums.next"
+mv "$test_dir/incomplete-checksums/checksums.next" "$test_dir/incomplete-checksums/checksums.txt"
+expect_failure 'an omitted checksum entry' "$test_dir/incomplete-checksums"
+
+copy_fixture corrupt-bundle
+printf '// changed after checksum generation\n' >> "$test_dir/corrupt-bundle/ui/bundle.js"
+expect_failure 'a corrupted UI bundle' "$test_dir/corrupt-bundle"
+
+copy_fixture wrong-platforms
+sed '/windows-amd64:/d' "$test_dir/wrong-platforms/manifest.yaml" > "$test_dir/wrong-platforms/manifest.next"
+mv "$test_dir/wrong-platforms/manifest.next" "$test_dir/wrong-platforms/manifest.yaml"
+write_checksums "$test_dir/wrong-platforms"
+expect_failure 'an unsupported runtime platform set' "$test_dir/wrong-platforms"
+
+archive="$test_dir/kandev-plugin-kandy-$version.tar.gz"
+tar -czf "$archive" -C "$test_dir/valid" .
+sh "$verify_script" "$archive" full >/dev/null
+if sh "$verify_script" "$test_dir/valid" unknown >/dev/null 2>&1; then
+ printf 'expected an unknown verification mode to fail\n' >&2
+ exit 1
+fi
+
+printf 'package verifier positive and negative checks passed\n'
diff --git a/scripts/test-verify-release-version.sh b/scripts/test-verify-release-version.sh
new file mode 100644
index 0000000..bfc78e3
--- /dev/null
+++ b/scripts/test-verify-release-version.sh
@@ -0,0 +1,68 @@
+#!/bin/sh
+set -eu
+
+repo_dir=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
+verify_script=$repo_dir/scripts/verify-release-version.sh
+test_dir=$(mktemp -d "${TMPDIR:-/tmp}/kandy-release-version-test.XXXXXX")
+trap 'rm -rf "$test_dir"' EXIT HUP INT TERM
+base_version=$(sed -nE 's/^version: "([^"]+)"$/\1/p' "$repo_dir/manifest.yaml")
+wrong_version=999.999.999
+[ "$wrong_version" != "$base_version" ] || wrong_version=999.999.998
+
+make_fixture() {
+ name=$1
+ fixture=$test_dir/$name
+ mkdir -p "$fixture"
+ cp "$repo_dir/Makefile" "$repo_dir/manifest.yaml" "$fixture/"
+}
+
+expect_failure() {
+ name=$1
+ fixture=$2
+ shift 2
+ if (cd "$fixture" && sh "$verify_script" "$@") > "$test_dir/output" 2>&1; then
+ printf 'expected release verification to reject %s\n' "$name" >&2
+ exit 1
+ fi
+}
+
+make_fixture valid
+(cd "$test_dir/valid" && sh "$verify_script" "v$base_version")
+
+make_fixture prerelease
+prerelease_version=0.15.0-rc.2
+sed "s/^version: \"$base_version\"$/version: \"$prerelease_version\"/" "$test_dir/prerelease/manifest.yaml" > "$test_dir/prerelease/manifest.next"
+mv "$test_dir/prerelease/manifest.next" "$test_dir/prerelease/manifest.yaml"
+sed "s/^VERSION := $base_version$/VERSION := $prerelease_version/" "$test_dir/prerelease/Makefile" > "$test_dir/prerelease/Makefile.next"
+mv "$test_dir/prerelease/Makefile.next" "$test_dir/prerelease/Makefile"
+(cd "$test_dir/prerelease" && sh "$verify_script" "v$prerelease_version")
+
+make_fixture wrong-tag
+expect_failure 'a tag that differs from manifest.yaml' "$test_dir/wrong-tag" "v$wrong_version"
+
+make_fixture wrong-manifest
+sed "s/^version: \"$base_version\"$/version: \"$wrong_version\"/" "$test_dir/wrong-manifest/manifest.yaml" > "$test_dir/wrong-manifest/manifest.next"
+mv "$test_dir/wrong-manifest/manifest.next" "$test_dir/wrong-manifest/manifest.yaml"
+expect_failure 'a manifest version that differs from Makefile' "$test_dir/wrong-manifest" "v$base_version"
+
+make_fixture wrong-makefile
+sed "s/^VERSION := $base_version$/VERSION := $wrong_version/" "$test_dir/wrong-makefile/Makefile" > "$test_dir/wrong-makefile/Makefile.next"
+mv "$test_dir/wrong-makefile/Makefile.next" "$test_dir/wrong-makefile/Makefile"
+expect_failure 'a Makefile version that differs from manifest.yaml' "$test_dir/wrong-makefile" "v$base_version"
+
+make_fixture wrong-package
+mkdir -p "$test_dir/wrong-package/archive"
+sed "s/^version: \"$base_version\"$/version: \"$wrong_version\"/" "$test_dir/wrong-package/manifest.yaml" > "$test_dir/wrong-package/archive/manifest.yaml"
+package_file="kandev-plugin-kandy-$base_version.tar.gz"
+tar -czf "$test_dir/wrong-package/$package_file" -C "$test_dir/wrong-package/archive" manifest.yaml
+expect_failure 'an archive manifest that differs from the tag' "$test_dir/wrong-package" "v$base_version" "$package_file"
+
+make_fixture matching-package
+mkdir -p "$test_dir/matching-package/archive"
+cp "$test_dir/matching-package/manifest.yaml" "$test_dir/matching-package/archive/manifest.yaml"
+package_file="kandev-plugin-kandy-$base_version.tar.gz"
+tar -czf "$test_dir/matching-package/$package_file" -C "$test_dir/matching-package/archive" manifest.yaml
+(cd "$test_dir/matching-package" && sh "$verify_script" "v$base_version" "$package_file")
+
+expect_failure 'a non-version release tag' "$test_dir/valid" "release-$base_version"
+printf 'release version positive and negative checks passed\n'
diff --git a/scripts/ui-smoke.html b/scripts/ui-smoke.html
new file mode 100644
index 0000000..9b80dda
--- /dev/null
+++ b/scripts/ui-smoke.html
@@ -0,0 +1,303 @@
+
+
+
+
+
+ Kandy packaged UI smoke
+
+
+
+
+
+
+ Preview loaded from the packaged Kandy bundle.
+ Kandy dialog opened from the action.
+
+
+
diff --git a/scripts/verify-package.sh b/scripts/verify-package.sh
new file mode 100644
index 0000000..3ed8ed0
--- /dev/null
+++ b/scripts/verify-package.sh
@@ -0,0 +1,117 @@
+#!/bin/sh
+set -eu
+
+fail() {
+ printf 'package verification failed: %s\n' "$1" >&2
+ exit 1
+}
+
+if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then
+ fail 'usage: verify-package.sh PACKAGE_FILE_OR_DIR full | host PLATFORM'
+fi
+
+input=$1
+mode=$2
+host_platform=${3-}
+package_dir=
+temporary_dir=
+
+if [ -d "$input" ]; then
+ package_dir=$(CDPATH= cd -- "$input" && pwd)
+elif [ -f "$input" ]; then
+ temporary_dir=$(mktemp -d "${TMPDIR:-/tmp}/kandy-package-verify.XXXXXX")
+ trap 'rm -rf "$temporary_dir"' EXIT HUP INT TERM
+ unsafe_path=$(tar -tzf "$input" | awk '
+ /^\// { print; exit }
+ /(^|\/)\.\.(\/|$)/ { print; exit }
+ ')
+ [ -z "$unsafe_path" ] || fail "archive contains an unsafe path: $unsafe_path"
+ tar -xzf "$input" -C "$temporary_dir" || fail 'could not extract package archive'
+ package_dir=$temporary_dir
+else
+ fail "package file or directory not found: $input"
+fi
+
+for required in manifest.yaml README.md ui/bundle.js checksums.txt; do
+ [ -f "$package_dir/$required" ] || fail "missing required file: $required"
+done
+
+manifest_id=$(sed -nE 's/^id: "([^"]+)"$/\1/p' "$package_dir/manifest.yaml")
+[ "$manifest_id" = "kandev-plugin-kandy" ] || fail "unexpected manifest id: ${manifest_id:-missing}"
+manifest_version=$(sed -nE 's/^version: "([^"]+)"$/\1/p' "$package_dir/manifest.yaml")
+make_version=$(sed -nE 's/^VERSION := ([^[:space:]]+)$/\1/p' "$(dirname "$0")/../Makefile")
+[ -n "$manifest_version" ] || fail 'manifest.yaml has no version'
+[ "$manifest_version" = "$make_version" ] || fail "manifest version $manifest_version differs from Makefile version $make_version"
+printf '%s\n' "$manifest_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' || fail "manifest version is invalid: $manifest_version"
+
+if [ -f "$input" ]; then
+ expected_archive="kandev-plugin-kandy-$manifest_version.tar.gz"
+ [ "$(basename "$input")" = "$expected_archive" ] || fail "package filename $(basename "$input") differs from $expected_archive"
+fi
+
+manifest_executables=$(awk '
+ $0 == "runtime:" { in_runtime = 1; next }
+ in_runtime && $0 == " executables:" { in_executables = 1; next }
+ in_executables && $0 !~ /^ / { exit }
+ in_executables && /^ [[:alnum:]_-]+: "[^\"]+"$/ {
+ platform = $1
+ sub(/:$/, "", platform)
+ path = $2
+ gsub(/"/, "", path)
+ print platform " " path
+ }
+' "$package_dir/manifest.yaml" | LC_ALL=C sort)
+expected_executables=$(printf '%s\n' \
+ 'darwin-amd64 server/plugin-darwin-amd64' \
+ 'darwin-arm64 server/plugin-darwin-arm64' \
+ 'linux-amd64 server/plugin-linux-amd64' \
+ 'linux-arm64 server/plugin-linux-arm64' \
+ 'windows-amd64 server/plugin-windows-amd64.exe' | LC_ALL=C sort)
+[ "$manifest_executables" = "$expected_executables" ] || fail 'manifest runtime.executables does not match the supported platform set'
+
+case "$mode" in
+ full)
+ executable_paths=$(printf '%s\n' "$manifest_executables" | awk '{ print $2 }')
+ ;;
+ host)
+ [ -n "$host_platform" ] || fail 'host mode requires a platform name'
+ executable_paths=$(printf '%s\n' "$manifest_executables" | awk -v platform="$host_platform" '$1 == platform { print $2 }')
+ [ -n "$executable_paths" ] || fail "host platform is not declared: $host_platform"
+ ;;
+ *)
+ fail "unknown verification mode: $mode"
+ ;;
+esac
+
+for executable in $executable_paths; do
+ [ -f "$package_dir/$executable" ] || fail "missing declared executable: $executable"
+done
+
+unexpected_entry=$(find "$package_dir" -mindepth 1 ! -type f ! -type d -print -quit)
+[ -z "$unexpected_entry" ] || fail "package contains a non-file entry: ${unexpected_entry#"$package_dir"/}"
+unexpected_dir=$(find "$package_dir" -mindepth 1 -type d -print | sed "s#^$package_dir/##" | awk '$0 != "server" && $0 != "ui" { print; exit }')
+[ -z "$unexpected_dir" ] || fail "package contains an unexpected directory: $unexpected_dir"
+
+expected_files=$(printf '%s\n' manifest.yaml README.md ui/bundle.js checksums.txt $executable_paths | LC_ALL=C sort)
+actual_files=$(cd "$package_dir" && find . -type f -print | sed 's#^\./##' | LC_ALL=C sort)
+[ "$actual_files" = "$expected_files" ] || {
+ printf 'unexpected package file inventory\nexpected:\n%s\nfound:\n%s\n' "$expected_files" "$actual_files" >&2
+ exit 1
+}
+
+checksum_entries=$(awk '
+ NF != 2 { invalid = 1; next }
+ { path = $2; sub(/^\*/, "", path); print path }
+ END { if (NR == 0 || invalid) exit 1 }
+' "$package_dir/checksums.txt") || fail 'checksums.txt has invalid lines'
+checksum_files=$(printf '%s\n' "$checksum_entries" | LC_ALL=C sort)
+expected_checksum_files=$(printf '%s\n' manifest.yaml README.md ui/bundle.js $executable_paths | LC_ALL=C sort)
+[ "$checksum_files" = "$expected_checksum_files" ] || fail 'checksums.txt does not list every package file exactly once'
+
+if command -v sha256sum >/dev/null 2>&1; then
+ (cd "$package_dir" && sha256sum -c checksums.txt) || fail 'checksum verification failed'
+elif command -v shasum >/dev/null 2>&1; then
+ (cd "$package_dir" && shasum -a 256 -c checksums.txt) || fail 'checksum verification failed'
+else
+ fail 'sha256sum or shasum is required'
+fi
diff --git a/scripts/verify-release-version.sh b/scripts/verify-release-version.sh
new file mode 100644
index 0000000..06869f2
--- /dev/null
+++ b/scripts/verify-release-version.sh
@@ -0,0 +1,33 @@
+#!/bin/sh
+set -eu
+
+fail() {
+ printf 'release version verification failed: %s\n' "$1" >&2
+ exit 1
+}
+
+if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then
+ fail 'usage: verify-release-version.sh TAG [PACKAGE_FILE]'
+fi
+
+tag=$1
+version_pattern='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'
+printf '%s\n' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' || fail "tag is not a release version: $tag"
+tag_version=${tag#v}
+manifest_version=$(sed -nE 's/^version: "([^"]+)"$/\1/p' manifest.yaml)
+make_version=$(sed -nE 's/^VERSION := ([^[:space:]]+)$/\1/p' Makefile)
+[ -n "$manifest_version" ] || fail 'manifest.yaml has no version'
+[ -n "$make_version" ] || fail 'Makefile has no VERSION'
+printf '%s\n' "$manifest_version" | grep -Eq "$version_pattern" || fail "manifest version is invalid: $manifest_version"
+printf '%s\n' "$make_version" | grep -Eq "$version_pattern" || fail "Makefile version is invalid: $make_version"
+[ "$manifest_version" = "$make_version" ] || fail "manifest version $manifest_version differs from Makefile version $make_version"
+[ "$tag_version" = "$manifest_version" ] || fail "tag $tag differs from manifest version $manifest_version"
+
+if [ "$#" -eq 2 ]; then
+ package_file=$2
+ expected_package=$(make -s package-file)
+ [ -f "$package_file" ] || fail "package file not found: $package_file"
+ [ "$(basename "$package_file")" = "$expected_package" ] || fail "package filename $(basename "$package_file") differs from $expected_package"
+ package_version=$(tar -xOzf "$package_file" manifest.yaml | sed -nE 's/^version: "([^"]+)"$/\1/p') || fail 'could not read package manifest'
+ [ "$package_version" = "$tag_version" ] || fail "package manifest version ${package_version:-missing} differs from tag $tag"
+fi
diff --git a/ui/bundle.js b/ui/bundle.js
index aefc93e..08e9e28 100644
--- a/ui/bundle.js
+++ b/ui/bundle.js
@@ -2722,11 +2722,11 @@ function sceneFor(biome, level, seed, timeOfDay, season, ancestors, ancestorBoun
// ---------------------------------------------------------------------------
var KANDY_CSS =
- // The host's chat topbar uses 28px controls on desktop and 44px touch
- // targets on phones. The ID selector keeps this plugin-owned geometry
- // authoritative over the utility classes on the shared host button.
- "#kandev-kandy-widget{width:28px;height:28px}" +
- "@media (max-width:639px){#kandev-kandy-widget{width:44px;height:44px}}" +
+ // Older hosts without ui.Action use the original button shell. Current
+ // hosts own the action dimensions and icon box in the chat top bar.
+ "#kandev-kandy-widget.kandev-kandy-widget-legacy{width:28px;height:28px}" +
+ "@media (max-width:639px){#kandev-kandy-widget.kandev-kandy-widget-legacy{width:44px;height:44px}}" +
+ ".kandev-kandy-widget-art{position:relative;display:inline-flex;align-items:center;justify-content:center}" +
// The shared TooltipContent always renders a small rotated-square arrow
// (a direct span child wrapping an svg). On our full-bleed scene card it
// reads as a stray floating square — hide it. :has() keeps the OTHER
@@ -3043,6 +3043,77 @@ function removeStyles() {
// Hover card + top-bar widget.
// ---------------------------------------------------------------------------
+var KANDY_ACTION_TRANSLATIONS = {
+ en: {
+ topbarActionAwake: "Kandy: level {{level}} {{stageName}}, {{mood}}",
+ topbarActionAsleep: "Kandy: level {{level}} {{stageName}}, {{mood}}, sleeping",
+ },
+};
+
+function kandyTopbarLabel(translate, shown, asleep) {
+ var level = shown.level;
+ var stageName = shown.stage_name;
+ var mood = shown.mood || "content";
+ var fallback = "Kandy: level " + level + " " + stageName + ", " + mood + (asleep ? ", sleeping" : "");
+ if (typeof translate !== "function") return fallback;
+
+ var key = asleep ? "topbarActionAsleep" : "topbarActionAwake";
+ var label = translate(key, {
+ defaultValue: fallback,
+ values: { level: level, stageName: stageName, mood: mood },
+ });
+ return typeof label === "string" && label && label !== key ? label : fallback;
+}
+
+function kandyTopbarAction(h, ui, options) {
+ var Action = ui && ui.Action;
+ var chipClass = "";
+ if (options.celebration) {
+ chipClass = bigCelebration(options.celebration) ? "kandev-kandy-levelup" : "kandev-kandy-celebrate";
+ } else if (options.greetFx) {
+ chipClass = "kandev-kandy-celebrate";
+ }
+
+ var icon = h(
+ "span",
+ {
+ className: "kandev-kandy-widget-art" + (chipClass ? " " + chipClass : ""),
+ "aria-hidden": "true",
+ },
+ creatureSvg(h, options.chipShown, Action ? 16 : 22, Action ? "size-4" : "", true),
+ );
+ var label = kandyTopbarLabel(options.translate, options.shown, options.chipAsleep);
+
+ if (Action) {
+ return h(Action, {
+ id: "kandev-kandy-widget",
+ label: label,
+ icon: icon,
+ // The Kandy card is the rich preview. Do not add a second, short host
+ // tooltip around the same trigger.
+ tooltip: "",
+ onMouseEnter: options.onLoad,
+ onFocus: options.onLoad,
+ onClick: options.onOpen,
+ });
+ }
+
+ return h(
+ "button",
+ {
+ id: "kandev-kandy-widget",
+ type: "button",
+ className:
+ "kandev-kandy-widget-legacy relative h-7 w-7 flex items-center justify-center cursor-pointer rounded-md border border-border/60 bg-muted/30 hover:bg-muted/60",
+ "aria-label": label,
+ onMouseEnter: options.onLoad,
+ onFocus: options.onLoad,
+ onClick: options.onOpen,
+ },
+ icon,
+ );
+}
+
var EGG_PLACEHOLDER = {
level: 1,
stage: 0,
@@ -7791,6 +7862,9 @@ function makeKandyWidget(host) {
var DialogTitle = ui.DialogTitle;
return function KandyWidget() {
+ var translation =
+ host.i18n && typeof host.i18n.useTranslation === "function" ? host.i18n.useTranslation() : null;
+ var translate = translation && translation.t;
var stateHook = React.useState(null);
var data = stateHook[0];
var setData = stateHook[1];
@@ -8911,47 +8985,26 @@ function makeKandyWidget(host) {
var chipAsleep = kandyAsleep;
if (chipAsleep) chipShown = Object.assign({}, shown, { sleep_state: "asleep" });
- // The chip is a real button: hover/focus gives the desktop quick-peek
- // tooltip, tap/click opens the same card as a dialog (touch devices
- // have no hover, so the dialog is the mobile path).
- var chipCelebrateCls = "";
- if (celebration) {
- chipCelebrateCls =
- bigCelebration(celebration) ? " kandev-kandy-levelup" : " kandev-kandy-celebrate";
- } else if (greetFx) {
- // The chip does its existing small hop alongside the arrival wave.
- chipCelebrateCls = " kandev-kandy-celebrate";
- }
- var trigger = h(
- "button",
- {
- id: "kandev-kandy-widget",
- type: "button",
- className:
- "relative h-7 w-7 flex items-center justify-center cursor-pointer rounded-md border border-border/60 bg-muted/30 hover:bg-muted/60" +
- chipCelebrateCls,
- "aria-label":
- "Kandy: level " +
- shown.level +
- " " +
- shown.stage_name +
- ", " +
- (shown.mood || "content") +
- (chipAsleep ? ", sleeping" : ""),
- onMouseEnter: load,
- onFocus: load,
- onClick: function () {
- load();
- returnToPhotoEntryRef.current = false;
- setPhotoOpen(false);
- setPhotoStatus("idle");
- setDialogOpen(true);
- // The dialog always greets on open (arrival gets the hop too).
- greetOnOpen();
- },
+ // Hover/focus keeps the desktop quick peek warm; clicking, tapping, or
+ // using the keyboard opens the same card as a dialog on every surface.
+ var trigger = kandyTopbarAction(h, ui, {
+ shown: shown,
+ chipShown: chipShown,
+ chipAsleep: chipAsleep,
+ translate: translate,
+ celebration: celebration,
+ greetFx: greetFx,
+ onLoad: load,
+ onOpen: function () {
+ load();
+ returnToPhotoEntryRef.current = false;
+ setPhotoOpen(false);
+ setPhotoStatus("idle");
+ setDialogOpen(true);
+ // The dialog always greets on open (arrival gets the hop too).
+ greetOnOpen();
},
- creatureSvg(h, chipShown, 22, "", true),
- );
+ });
// Shared interaction wiring for BOTH card surfaces (hover preview and
// click dialog): treat on click, bucket on right-click, plus the fx
@@ -9221,6 +9274,9 @@ window.registerKandevPlugin(PLUGIN_ID, {
initialize: function (registry, host) {
h0 = host.jsx;
injectStyles();
+ if (typeof registry.registerTranslations === "function") {
+ registry.registerTranslations(KANDY_ACTION_TRANSLATIONS);
+ }
registry.registerComponent("chat-top-bar", makeKandyWidget(host));
// Live updates: refetch when work happens, instead of waiting for the
// backstop poll (or a page reload).
@@ -9255,6 +9311,8 @@ window.registerKandevPlugin(PLUGIN_ID, {
photoAncestorsFor: photoAncestorsFor,
growthForLevel: growthForLevel,
kandyCard: kandyCard,
+ kandyTopbarAction: kandyTopbarAction,
+ kandyTopbarLabel: kandyTopbarLabel,
petOverlay: petOverlay,
bonkOverlay: bonkOverlay,
distrustOverlay: distrustOverlay,
diff --git a/ui/bundle.test.js b/ui/bundle.test.js
index 1630bda..47f04bc 100644
--- a/ui/bundle.test.js
+++ b/ui/bundle.test.js
@@ -103,6 +103,89 @@ function findNode(root, predicate) {
return found;
}
+function makeHookHarness() {
+ let index = 0;
+ const slots = [];
+ const stateUpdates = [];
+ const React = {
+ Fragment: "react.fragment",
+ useState(initialValue) {
+ const slot = index++;
+ if (!slots[slot]) {
+ slots[slot] = {
+ kind: "state",
+ value: typeof initialValue === "function" ? initialValue() : initialValue,
+ };
+ }
+ return [slots[slot].value, (value) => stateUpdates.push([slot, value])];
+ },
+ useRef(initialValue) {
+ const slot = index++;
+ if (!slots[slot]) slots[slot] = { kind: "ref", value: { current: initialValue } };
+ return slots[slot].value;
+ },
+ useEffect() {},
+ useLayoutEffect() {},
+ };
+ return {
+ React,
+ stateUpdates,
+ render(component) {
+ index = 0;
+ return component();
+ },
+ };
+}
+
+function renderTopbarWidget(options = {}) {
+ const { document, plugin } = loadBundle();
+ const harness = makeHookHarness();
+ const ui = {
+ Tooltip: "host.Tooltip",
+ TooltipTrigger: "host.TooltipTrigger",
+ TooltipContent: "host.TooltipContent",
+ Dialog: "host.Dialog",
+ DialogContent: "host.DialogContent",
+ DialogTitle: "host.DialogTitle",
+ };
+ if (options.withAction) ui.Action = function HostAction() {};
+ const fetchCalls = [];
+ const registrations = [];
+ const host = {
+ React: harness.React,
+ jsx,
+ ui,
+ api: {
+ fetch(path) {
+ fetchCalls.push(path);
+ return Promise.resolve({ json: () => Promise.resolve(sampleKandy()) });
+ },
+ },
+ };
+ if (options.translation) {
+ host.i18n = {
+ useTranslation() {
+ return { t: options.translation };
+ },
+ };
+ }
+ const registry = {
+ registerComponent(slot, component) {
+ registrations.push({ slot, component });
+ },
+ registerWsHandler() {},
+ };
+ let translations = null;
+ if (options.withTranslations) {
+ registry.registerTranslations = (catalogs) => {
+ translations = catalogs;
+ };
+ }
+ plugin.initialize(registry, host);
+ const tree = harness.render(registrations[0].component);
+ return { document, fetchCalls, harness, plugin, registrations, translations, tree, ui };
+}
+
function sampleKandy(overrides = {}) {
return Object.assign(
{
@@ -1458,23 +1541,134 @@ test("token grotto resolves removed chambers to hub and subscribes to live usage
assert.ok(actions.includes("session.prompt_usage"));
});
-test("chat topbar control uses desktop and phone geometry", () => {
- const { document, plugin } = loadBundle();
- plugin.initialize(
- {
- registerComponent() {},
- registerWsHandler() {},
- },
- { jsx, ui: {} },
- );
+test("chat topbar uses host Action with one decorative creature inside the host glyph", () => {
+ const { document, tree, ui } = renderTopbarWidget({ withAction: true });
+ const wrapper = findNode(tree, (node) => node.type === ui.TooltipTrigger);
+ const action = wrapper.props.children;
+ assert.equal(action.type, ui.Action);
+ assert.equal(action.props.id, "kandev-kandy-widget");
+ assert.equal(action.props.label, "Kandy: level 1 Egg, content");
+ assert.equal(action.props.tooltip, "");
+ assert.equal(typeof action.props.onMouseEnter, "function");
+ assert.equal(typeof action.props.onFocus, "function");
+ assert.equal(typeof action.props.onClick, "function");
+ assert.equal("className" in action.props, false);
+ assert.equal("style" in action.props, false);
+ assert.equal("size" in action.props, false);
+ assert.equal(action.props.children, undefined);
+
+ const icon = action.props.icon;
+ assert.equal(icon.type, "span");
+ assert.equal(icon.props["aria-hidden"], "true");
+ assert.equal(icon.props.children.type, "svg");
+ assert.equal(icon.props.children.props.width, 16);
+ assert.equal(icon.props.children.props.height, 16);
+ assert.match(icon.props.children.props.className, /\bsize-4\b/);
+ assert.equal(icon.props.children.props["aria-hidden"], "true");
- const style = document.getElementById("kandev-kandy-style");
- assert.ok(style);
- assert.match(style.textContent, /#kandev-kandy-widget[^}]*width:28px[^}]*height:28px/);
+ const css = document.getElementById("kandev-kandy-style").textContent;
+ assert.doesNotMatch(css, /#kandev-kandy-widget\{[^}]*width/);
+ assert.match(css, /#kandev-kandy-widget\.kandev-kandy-widget-legacy\{width:28px;height:28px\}/);
assert.match(
- style.textContent,
- /@media \(max-width:639px\)\{#kandev-kandy-widget[^}]*width:44px[^}]*height:44px/,
+ css,
+ /@media \(max-width:639px\)\{#kandev-kandy-widget\.kandev-kandy-widget-legacy\{width:44px;height:44px\}/,
+ );
+});
+
+test("chat topbar falls back to the native control and keeps its legacy target", () => {
+ const { tree } = renderTopbarWidget();
+ const trigger = findNode(tree, (node) => node.type === "host.TooltipTrigger").props.children;
+ assert.equal(trigger.type, "button");
+ assert.equal(trigger.props.type, "button");
+ assert.match(trigger.props.className, /kandev-kandy-widget-legacy/);
+ assert.equal(trigger.props["aria-label"], "Kandy: level 1 Egg, content");
+ assert.equal(trigger.props.children.type, "span");
+ assert.equal(trigger.props.children.props.children.props.width, 22);
+});
+
+test("chat topbar registers English catalog and uses host-localized accessible copy", () => {
+ const calls = [];
+ const { tree, translations, ui } = renderTopbarWidget({
+ withAction: true,
+ withTranslations: true,
+ translation(key, options) {
+ calls.push([key, options]);
+ const prefix = key === "topbarActionAsleep" ? "Localized sleeping" : "Localized awake";
+ return `${prefix}: level ${options.values.level} ${options.values.stageName}, ${options.values.mood}`;
+ },
+ });
+ const action = findNode(tree, (node) => node.type === ui.Action);
+ assert.equal(action.props.label, "Localized awake: level 1 Egg, content");
+ assert.deepEqual(Object.keys(translations).sort(), ["en"]);
+ assert.equal(translations.en.topbarActionAwake, "Kandy: level {{level}} {{stageName}}, {{mood}}");
+ assert.equal(
+ translations.en.topbarActionAsleep,
+ "Kandy: level {{level}} {{stageName}}, {{mood}}, sleeping",
);
+ assert.equal(calls[0][0], "topbarActionAwake");
+ assert.equal(calls[0][1].values.level, 1);
+ assert.equal(calls[0][1].values.stageName, "Egg");
+ assert.equal(calls[0][1].values.mood, "content");
+ assert.equal(calls[0][1].defaultValue, "Kandy: level 1 Egg, content");
+
+ const label = loadBundle().plugin.__render.kandyTopbarLabel;
+ const sleeping = label(
+ (key, options) => {
+ assert.equal(key, "topbarActionAsleep");
+ return `Dormant Kandy level ${options.values.level} ${options.values.stageName}, ${options.values.mood}`;
+ },
+ { level: 8, stage_name: "Cloud Sporeling", mood: "calm" },
+ true,
+ );
+ assert.equal(sleeping, "Dormant Kandy level 8 Cloud Sporeling, calm");
+ assert.equal(
+ label(null, { level: 8, stage_name: "Cloud Sporeling", mood: "calm" }, true),
+ "Kandy: level 8 Cloud Sporeling, calm, sleeping",
+ );
+});
+
+test("topbar hover, focus, and activation keep the preview, data fetch, and dialog path", async () => {
+ const { fetchCalls, harness, registrations, tree, ui } = renderTopbarWidget({ withAction: true });
+ assert.equal(registrations.length, 1);
+ assert.equal(registrations[0].slot, "chat-top-bar");
+ const wrapper = findNode(tree, (node) => node.type === ui.Tooltip);
+ assert.ok(findNode(wrapper, (node) => node.type === ui.TooltipContent));
+ const action = findNode(tree, (node) => node.type === ui.Action);
+ action.props.onMouseEnter({ type: "mouseenter" });
+ action.props.onFocus({ type: "focus" });
+ action.props.onClick({ type: "click" });
+
+ assert.deepEqual(fetchCalls, ["webhooks/kandy", "webhooks/kandy", "webhooks/kandy"]);
+ assert.ok(harness.stateUpdates.some(([slot, value]) => slot === 1 && value === true));
+ await Promise.resolve();
+ await Promise.resolve();
+});
+
+test("disable and re-enable removes and restores the widget style and registration", () => {
+ const { document, plugin } = loadBundle();
+ const registrations = [];
+ const handlers = [];
+ const registry = {
+ registerComponent(slot, component) {
+ registrations.push({ slot, component });
+ },
+ registerWsHandler(action) {
+ handlers.push(action);
+ },
+ };
+ const host = { jsx, ui: {}, React: {} };
+ plugin.initialize(registry, host);
+ assert.ok(document.getElementById("kandev-kandy-style"));
+ plugin.destroy();
+ assert.equal(document.getElementById("kandev-kandy-style"), null);
+ plugin.initialize(registry, host);
+
+ assert.equal(registrations.length, 2);
+ assert.ok(registrations.every((entry) => entry.slot === "chat-top-bar"));
+ assert.notEqual(registrations[0].component, registrations[1].component);
+ assert.ok(document.getElementById("kandev-kandy-style"));
+ assert.ok(handlers.length > 0);
+ plugin.destroy();
});
test("token grotto CSS uses vertical responsive grids without paging tracks", () => {