add a foreground dev launcher and close the Arco token gaps
Build / build (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s

The CYBER skin left a class of defects that unit tests could not see: Arco
components kept their own light-theme appearance because the token bridge
never covered the neutral, gray, tooltip, mask and border-radius families,
and four override rules targeted class names that do not exist in Arco, so
they silently matched nothing.

Bridge the missing families and point the dead selectors at the real ones
(.arco-switch-handle, .arco-checkbox-icon, .arco-select-dropdown,
.arco-slider-bar), so switches stop rendering a white knob and checkboxes
finally glow.

Add start.sh so a dev run can be started without a second terminal: it
frees the ports left over from a previous run, refusing to kill processes
that do not belong to this project, then starts the API server and the
Vite client in the foreground.
This commit is contained in:
2026-10-21 10:13:38 +08:00
committed by trae-solo-agent
parent bbb9a6b61f
commit 8fdec02a1c
2 changed files with 325 additions and 3 deletions
+122 -3
View File
@@ -131,9 +131,59 @@ body[arco-theme='dark'],
--color-fill-2-hover: oklch(0.255 0.018 247);
--color-fill-3-hover: oklch(0.285 0.018 246);
// Arco 的 neutral / gray 族:边框线、树连接线、空状态图标都走这两族。
// 不桥接就会漏出 Arco 自带的中性灰(暗色下 rgb(72,72,73) 之类),与 CYBER 的发丝线体系脱节。
--gray-1: 28, 30, 36;
--gray-2: 40, 43, 51;
--gray-3: 52, 56, 66;
--gray-4: 66, 70, 82;
--gray-5: 92, 97, 110;
--gray-6: 118, 123, 136;
--gray-7: 144, 149, 162;
--gray-8: 170, 175, 188;
--gray-9: 196, 201, 214;
--gray-10: 226, 231, 240;
--color-neutral-1: rgb(var(--gray-1));
--color-neutral-2: rgb(var(--gray-2));
--color-neutral-3: rgb(var(--gray-3));
--color-neutral-4: rgb(var(--gray-4));
--color-neutral-5: rgb(var(--gray-5));
--color-neutral-6: rgb(var(--gray-6));
--color-neutral-7: rgb(var(--gray-7));
--color-neutral-8: rgb(var(--gray-8));
--color-neutral-9: rgb(var(--gray-9));
--color-neutral-10: rgb(var(--gray-10));
--color-neutral-1-hover: rgb(var(--gray-2));
--color-neutral-2-hover: rgb(var(--gray-3));
--color-neutral-3-hover: rgb(var(--gray-4));
--color-neutral-4-hover: rgb(var(--gray-5));
// 次级按钮的半透明底:跟灰阶走,保持低对比
--color-secondary-1: rgba(var(--gray-9), 0.08);
--color-secondary-2: rgba(var(--gray-9), 0.12);
--color-secondary-3: rgba(var(--gray-9), 0.16);
--color-secondary-4: rgba(var(--gray-9), 0.20);
// 浮层与遮罩
--color-bg-popup: oklch(0.185 0.014 248);
--color-tooltip-bg: oklch(0.225 0.016 246);
--color-bg-white: oklch(0.945 0.006 200);
--color-mask-bg: var(--overlay-scrim);
--color-spin-layer-bg: var(--overlay-scrim);
--color-menu-dark-bg: oklch(0.185 0.014 248);
--color-menu-light-bg: oklch(0.185 0.014 248);
// Arco 的圆角族名与 CYBER 的 --radius-* 不同,两族都要给,
// 否则 Arco 组件会继续用它的 2/4/8px,和我们声明的 4/6/8px 对不上。
--radius-small: 4px;
--radius-medium: 6px;
--radius-large: 8px;
--border-radius-none: 0px;
--border-radius-small: 4px;
--border-radius-medium: 6px;
--border-radius-large: 8px;
--font-size-body-1: 14px;
--font-size-body-2: 13px;
@@ -535,21 +585,43 @@ $neon-hv: var(--accent-hover);
box-shadow: 0 0 10px rgba(var(--accent-primary-rgb), 0.3);
}
.arco-switch-checked .arco-switch-slider {
// 开关手柄。Arco 的类名是 .arco-switch-handle(不是 .arco-switch-slider,后者不存在),
// 底色默认走 --color-bg-white,暗色下是一块亮白,必须接管。
.arco-switch-handle {
background-color: var(--text-primary);
box-shadow: 0 0 8px rgba(var(--accent-primary-rgb), 0.45);
}
.arco-switch-checked .arco-switch-handle {
background-color: var(--accent-primary);
box-shadow: 0 0 10px rgba(var(--accent-primary-rgb), 0.7);
}
.arco-checkbox-checked .arco-checkbox-inner {
// 复选框图标。Arco 的类名是 .arco-checkbox-icon(不是 .arco-checkbox-inner,后者不存在),
// 不写对类名,选中态的外发光就完全不生效。
.arco-checkbox-checked .arco-checkbox-icon {
background-color: var(--accent-primary);
border-color: var(--accent-primary);
box-shadow: 0 0 8px rgba(var(--accent-primary-rgb), 0.5);
}
// 单选按钮组:选中态给霓虹管壁,和按钮同一套语言
.arco-radio-button.arco-radio-checked {
color: var(--accent-primary);
background-color: rgba(var(--accent-primary-rgb), 0.12);
border-color: rgba(var(--accent-primary-rgb), 0.5);
box-shadow: inset 0 0 10px rgba(var(--accent-primary-rgb), 0.14);
}
.arco-radio-button:not(:first-of-type)::before {
border-color: var(--border-light);
}
// 下拉 / 弹层的浮层背景。浮层要浮在内容之上,用最亮的一层底。
.arco-trigger-popup,
.arco-select-popup,
.arco-picker-dropdown,
.arco-select-dropdown,
.arco-picker-container,
.arco-dropdown,
.arco-modal,
.arco-drawer {
@@ -558,6 +630,53 @@ $neon-hv: var(--accent-hover);
box-shadow: var(--shadow-lg);
}
// 浮层内部:Arco 把下拉面板挂在 body 下,不在 .arco-trigger-popup 内,
// 所以 message / notification / popover / popconfirm 都要单独接管。
// 它们用 --color-bg-popup,桥接后与 modal 同为 --bg-card 一档。
.arco-message,
.arco-notification,
.arco-popover-popup-content,
.arco-popconfirm-popup-content {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
color: var(--text-primary);
}
.arco-message .arco-message-content,
.arco-notification .arco-notification-content,
.arco-popover-popup-content,
.arco-popconfirm-popup-content {
color: var(--text-primary);
}
// 气泡确认框的操作区要压暗一档,否则和内容区糊在一起
.arco-popconfirm-popup-footer {
border-top: 1px solid var(--border-light);
}
// Tooltip:Arco 的提示框用 --color-tooltip-bg,箭头和内容框同色。
// 这里让它比弹层亮一档,和弹层区分开。
.arco-tooltip-content {
background-color: var(--color-tooltip-bg);
color: var(--text-primary);
font-family: $font-code;
}
.arco-tooltip-popup-arrow::before {
background-color: var(--color-tooltip-bg);
}
// 抽屉 / 弹窗头尾的分隔线
.arco-drawer-header,
.arco-modal-header {
border-color: var(--border-light);
}
.arco-drawer-footer,
.arco-modal-footer {
border-color: var(--border-light);
}
// 表格:去掉亮色分隔线的突兀感,表头沉下去
.arco-table-th {
background-color: var(--bg-secondary);
Executable
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env bash
#
# Local development launcher for hermes-ui.
#
# Starts the Koa API server and the Vite client together in the foreground,
# so you can watch the logs and Ctrl-C to stop everything.
#
# ./start.sh
#
# Then open http://localhost:8649/ and sign in with admin / 123456.
#
# The API server listens on 8647 and the Vite dev server proxies /api and
# /socket.io to it. Both ports are overridable:
#
# API_PORT=9000 CLIENT_PORT=9001 ./start.sh
#
# Ports left over from a previous run are cleaned up automatically.
#
set -uo pipefail
cd "$(dirname "$0")"
API_PORT="${API_PORT:-8647}"
CLIENT_PORT="${CLIENT_PORT:-8649}"
bold() { printf '\033[1m%s\033[0m\n' "$1"; }
info() { printf '\033[36m›\033[0m %s\n' "$1"; }
warn() { printf '\033[33m!\033[0m %s\n' "$1"; }
die() { printf '\033[31m✗\033[0m %s\n' "$1" >&2; exit 1; }
# --- Preflight -----------------------------------------------------------
command -v node >/dev/null 2>&1 || die "node not found. Install Node.js >= 23."
node_version="$(node -v)"
node_major="${node_version#v}"
node_major="${node_major%%.*}"
if [ "$node_major" -lt 23 ]; then
die "Node.js >= 23 required, found $node_version."
fi
info "node $node_version"
if [ ! -d node_modules ]; then
warn "node_modules missing — installing (this takes a few minutes)."
npm ci --ignore-scripts || die "npm ci failed."
# node-pty's install script is what places the loadable native module.
# `npm ci --ignore-scripts` skips it, which silently disables the terminal.
info "rebuilding node-pty"
npm rebuild node-pty || warn "node-pty rebuild failed — the terminal may not work."
elif ! node -e "require('node-pty')" >/dev/null 2>&1; then
warn "node-pty native module missing — rebuilding (needed for the terminal)."
npm rebuild node-pty || warn "node-pty rebuild failed — the terminal may not work."
fi
# --- Port cleanup --------------------------------------------------------
#
# A previous run can leave vite, the ts-node server, and their supervisor
# behind. The Vite client runs with --strictPort, so it refuses to fall back
# to another port and dies instead. Clean all of it before starting.
# PIDs listening on a TCP port, one per line. Always exits 0 — `lsof` returns
# non-zero when nothing matches, which under `set -e` would abort the script.
pids_on_port() {
lsof -nP -iTCP:"$1" -sTCP:LISTEN -t 2>/dev/null | sort -u || true
}
port_in_use() {
local pids
pids="$(pids_on_port "$1")"
[ -n "$pids" ]
}
# Whether a PID belongs to this project. Guards against killing an unrelated
# process that merely happens to hold the port.
pid_is_ours() {
local pid="$1" cmd cwd
cmd="$(ps -o command= -p "$pid" 2>/dev/null || true)"
case "$cmd" in
*hermes-ui*|*"packages/server/src/index.ts"*|*node_modules/.bin/vite*) return 0 ;;
esac
# A plain `node` process running from inside the project directory is a
# leftover dev server too — e.g. one started by hand from this repo.
case "$cmd" in
node*) ;;
*) return 1 ;;
esac
cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1 || true)"
case "$cwd" in
"$PWD"/*|"$PWD") return 0 ;;
esac
return 1
}
# Walk up from a PID to the topmost ancestor that still belongs to this
# project. Killing only the listener leaves its npm/nodemon parent alive to
# respawn it, which is how stale servers come back.
top_of_tree() {
local top="$1" parent parent_cmd
while true; do
parent="$(ps -o ppid= -p "$top" 2>/dev/null | tr -d ' ' || true)"
[ -n "$parent" ] || break
[ "$parent" = "1" ] && break
[ "$parent" = "$$" ] && break
parent_cmd="$(ps -o command= -p "$parent" 2>/dev/null || true)"
case "$parent_cmd" in
*hermes-ui*|*concurrently*|*cross-env*|*nodemon*|*"npm run dev"*) top="$parent" ;;
*) break ;;
esac
done
echo "$top"
}
# Kill a PID once. The supervisor is reachable from several ports, so the
# same PID can be offered more than once — remember what we already killed.
# A plain string, not an associative array: macOS ships bash 3.2, which has
# no `declare -A`.
KILLED_PIDS=" "
kill_pid() {
local pid="$1" signal="${2:-TERM}" cmd
cmd="$(ps -o command= -p "$pid" 2>/dev/null || true)"
[ -n "$cmd" ] || return 0
case "$KILLED_PIDS" in
*" $pid "*) return 0 ;;
esac
KILLED_PIDS="$KILLED_PIDS$pid "
warn "killing pid $pid ($cmd)"
kill "-$signal" "$pid" 2>/dev/null || true
}
free_port() {
local port="$1" pids pid top waited
pids="$(pids_on_port "$port")"
[ -n "$pids" ] || return 0
for pid in $pids; do
if ! pid_is_ours "$pid"; then
die "port $port is held by a process that is not this project (pid $pid). Stop it yourself, or set $port to a free port."
fi
done
for pid in $pids; do
top="$(top_of_tree "$pid")"
kill_pid "$top"
done
# Give them a moment to release the socket.
waited=0
while [ "$waited" -lt 30 ]; do
port_in_use "$port" || return 0
sleep 0.2
waited=$((waited + 1))
done
# Still held — SIGKILL the stragglers.
for pid in $(pids_on_port "$port"); do
kill_pid "$pid" KILL
done
sleep 0.5
}
# Kill a leftover `npm run dev` supervisor before taking the ports. If it
# stays alive it will respawn the children we just killed.
kill_stale_supervisor() {
local pid
for pid in $(pgrep -f 'npm run dev:server' 2>/dev/null || true); do
[ "$pid" = "$$" ] && continue
pid_is_ours "$pid" || continue
kill_pid "$pid"
done
for pid in $(pgrep -f 'npm run dev:client' 2>/dev/null || true); do
[ "$pid" = "$$" ] && continue
pid_is_ours "$pid" || continue
kill_pid "$pid"
done
for pid in $(pgrep -f 'npm run dev$' 2>/dev/null || true); do
[ "$pid" = "$$" ] && continue
pid_is_ours "$pid" || continue
kill_pid "$pid"
done
sleep 0.5
}
kill_stale_supervisor
free_port "$API_PORT"
free_port "$CLIENT_PORT"
# --- Launch --------------------------------------------------------------
bold "hermes-ui — starting"
info "api http://localhost:$API_PORT"
info "client http://localhost:$CLIENT_PORT (login: admin / 123456)"
echo
# `npm run dev` runs both halves under concurrently: the server through
# nodemon (hot restart on server edits) and the client through Vite.
# HERMES_WEB_UI_BACKEND_PORT tells Vite which port to proxy /api and
# /socket.io to; PORT tells the Koa server where to listen.
export PORT="$API_PORT"
export HERMES_WEB_UI_BACKEND_PORT="$API_PORT"
npm run dev