🤞 Sync 2026-08-10 09:21:08

This commit is contained in:
github-actions[bot]
2026-08-10 09:21:08 +08:00
parent c463113ea9
commit 2f0ec626c4
17 changed files with 392 additions and 118 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=gecoosac
PKG_VERSION:=2.2.20251015
PKG_RELEASE:=19
PKG_RELEASE:=20
PKG_MAINTAINER:=Roc Lai <laipeng668@qq.com>
PKG_LICENSE:=AGPL-3.0-only
+62 -33
View File
@@ -86,50 +86,62 @@ normalize_path() {
printf '%s\n' "$normalized"
}
path_has_clear_stage_component() {
local path="$1" part rest
path="$(normalize_path "$path")" || return 1
rest="${path#/}"
while [ -n "$rest" ]; do
part="${rest%%/*}"
if [ "$part" = "$rest" ]; then
rest=""
else
rest="${rest#*/}"
fi
case "$part" in
.gecoosac-clear.*) return 0 ;;
esac
done
return 1
}
path_uses_clear_stage() {
local path="$1" resolved
path="$(normalize_path "$path")" || return 1
case "$path" in
/.gecoosac-clear.*|/.gecoosac-clear.*/*|*/.gecoosac-clear.*|*/.gecoosac-clear.*/*)
return 0
;;
esac
if [ -e "$1" ] || [ -L "$1" ]; then
resolved="$(readlink -f "$1" 2>/dev/null)" || return 1
resolved="$(normalize_path "$resolved")" || return 1
case "$resolved" in
/.gecoosac-clear.*|/.gecoosac-clear.*/*|*/.gecoosac-clear.*|*/.gecoosac-clear.*/*)
return 0
;;
esac
path_has_clear_stage_component "$path" && return 0
if [ -e "$path" ] || [ -L "$path" ]; then
resolved="$(readlink -f "$path" 2>/dev/null)" || return 1
path_has_clear_stage_component "$resolved" && return 0
fi
return 1
}
is_supported_upload_path() {
local path="$1" storage
path="$(normalize_path "$path")" || return 1
path_uses_clear_stage "$path" && return 1
[ "$path" = "$DEFAULT_UPLOAD_DIR" ] && return 0
case "$path" in
/mnt/*/gecoosac/upload)
storage="${path#/mnt/}"
storage="${storage%/gecoosac/upload}"
[ -n "$storage" ] && [ "${storage#*/}" = "$storage" ]
;;
*) return 1 ;;
esac
}
is_safe_upload_dir() {
local path physical
path="$(normalize_path "$1")" || return 1
physical="$(managed_dir_path upload "$path")" || return 1
case "$path" in
/etc/gecoosac|/etc/gecoosac/*) return 1 ;;
esac
case "$physical" in
/etc/gecoosac|/etc/gecoosac/*) return 1 ;;
esac
case "$path" in
*/gecoosac/upload) ;;
*) return 1 ;;
esac
case "$physical" in
*/gecoosac/upload) return 0 ;;
esac
return 1
is_supported_upload_path "$path" || return 1
is_supported_upload_path "$physical"
}
is_path_in_dir() {
@@ -614,18 +626,35 @@ managed_dir_path() {
esac
}
ensure_upload_storage_root() {
local path="$1" root
is_supported_upload_path "$path" || return 1
case "$path" in
"$DEFAULT_UPLOAD_DIR") root=/tmp ;;
/mnt/*/gecoosac/upload) root="${path%/gecoosac/upload}" ;;
*) return 1 ;;
esac
[ -d "$root" ] && [ ! -L "$root" ] || return 1
is_secure_upload_dir "$root"
}
ensure_managed_dir() {
local role="$1" path physical
path="$(normalize_path "$2")" || return 1
physical="$(managed_dir_path "$role" "$path")" || return 1
case "$role" in
upload) is_safe_upload_dir "$path" || return 1 ;;
upload)
is_safe_upload_dir "$path" || return 1
ensure_upload_storage_root "$physical" || return 1
;;
db) is_safe_db_dir "$path" "$upload_dir" || return 1 ;;
pid) is_safe_pid_dir "$path" "$upload_dir" || return 1 ;;
tls) [ "$path" = "/etc/gecoosac/tls" ] || return 1 ;;
*) return 1 ;;
esac
physical="$(managed_dir_path "$role" "$path")" || return 1
ensure_secure_dir_tree "$physical" || return 1
[ -d "$path" ]
}
+54 -19
View File
@@ -90,27 +90,62 @@ managed_dir_path() {
esac
}
path_has_clear_stage_component() {
local path="$1" part rest
path="$(normalize_path "$path")" || return 1
rest="${path#/}"
while [ -n "$rest" ]; do
part="${rest%%/*}"
if [ "$part" = "$rest" ]; then
rest=""
else
rest="${rest#*/}"
fi
case "$part" in
.gecoosac-clear.*) return 0 ;;
esac
done
return 1
}
path_uses_clear_stage() {
local path="$1" resolved
path_has_clear_stage_component "$path" && return 0
if [ -e "$path" ] || [ -L "$path" ]; then
resolved="$(readlink -f "$path" 2>/dev/null)" || return 1
path_has_clear_stage_component "$resolved" && return 0
fi
return 1
}
is_supported_upload_path() {
local path="$1" storage
path="$(normalize_path "$path")" || return 1
path_uses_clear_stage "$path" && return 1
[ "$path" = "$DEFAULT_UPLOAD_DIR" ] && return 0
case "$path" in
/mnt/*/gecoosac/upload)
storage="${path#/mnt/}"
storage="${storage%/gecoosac/upload}"
[ -n "$storage" ] && [ "${storage#*/}" = "$storage" ]
;;
*) return 1 ;;
esac
}
is_safe_upload_dir() {
local path physical
path="$(normalize_path "$1")" || return 1
physical="$(managed_dir_path upload "$path")" || return 1
case "$path" in
/etc/gecoosac|/etc/gecoosac/*) return 1 ;;
esac
case "$physical" in
/etc/gecoosac|/etc/gecoosac/*) return 1 ;;
esac
case "$path" in
*/gecoosac/upload) ;;
*) return 1 ;;
esac
case "$physical" in
*/gecoosac/upload) return 0 ;;
esac
return 1
is_supported_upload_path "$path" || return 1
is_supported_upload_path "$physical"
}
is_path_in_dir() {
@@ -247,7 +282,7 @@ cleanup_legacy_upload_dir() {
normalize_dir_option() {
local option="$1"
local validator="$3"
local validator="$2"
local value normalized upload_dir
value="$(uci -q get "gecoosac.config.${option}")"
@@ -320,8 +355,8 @@ migrate_config() {
ensure_section || exit 1
migrate_config || exit 1
normalize_upload_dir || exit 1
normalize_dir_option db_dir "$DEFAULT_DB_DIR" is_safe_db_dir || exit 1
normalize_dir_option piddir "$DEFAULT_PID_DIR" is_safe_pid_dir || exit 1
normalize_dir_option db_dir is_safe_db_dir || exit 1
normalize_dir_option piddir is_safe_pid_dir || exit 1
set_default enabled 0 || exit 1
set_default port 60650 || exit 1
set_default isonlyoneprot 1 || exit 1
+11 -2
View File
@@ -10,8 +10,8 @@ include $(TOPDIR)/rules.mk
PKG_ARCH_ghttpd:=$(ARCH)
PKG_NAME:=ghttpd
PKG_VERSION:=0.0.2
PKG_RELEASE:=4
PKG_VERSION:=0.0.3
PKG_RELEASE:=5
PKG_SOURCE:=$(PKG_NAME)-binary-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://github.com/Carseason/openwrt-packages/releases/download/prebuilt/
PKG_HASH:=skip
@@ -49,6 +49,15 @@ if [ -z "$${IPKG_INSTROOT}" ]; then
fi
endef
define Package/$(PKG_NAME)/postrm
#!/bin/sh
for service in uhttpd nginx; do
[ -x "/etc/init.d/$$service" ] || continue
"/etc/init.d/$$service" enabled && "/etc/init.d/$$service" restart
done
exit 0
endef
define Build/Configure
endef
+1 -1
View File
@@ -19,7 +19,7 @@ stop_default_httpd_services() {
start_default_httpd_services() {
for service in uhttpd nginx; do
[ -x "/etc/init.d/$service" ] || continue
"/etc/init.d/$service" enabled && "/etc/init.d/$service" start
"/etc/init.d/$service" enabled && "/etc/init.d/$service" restart
done
}
+1 -1
View File
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-gecoosac
PKG_VERSION:=2.2
PKG_RELEASE:=19
PKG_RELEASE:=20
LUCI_TITLE:=LuCI Support for gecoosac
LUCI_DEPENDS:=+luci-base +gecoosac +gecoosac-files
@@ -11,7 +11,6 @@ const DEFAULT_DB_DIR = '/etc/gecoosac';
const DEFAULT_CRT_FILE = '/etc/gecoosac/tls/gecoosac.crt';
const DEFAULT_KEY_FILE = '/etc/gecoosac/tls/gecoosac.key';
const DEFAULT_PID_DIR = '/var/run';
const CONFIG_BACKUP_DIR = '/etc/gecoosac';
const DB_DIR_PREFIXES = [ '/etc/gecoosac', '/tmp/gecoosac', '/var/lib/gecoosac' ];
const PID_DIR_PREFIXES = [ '/var/run', '/tmp/gecoosac' ];
const CLEAR_STAGE_PATH_ERROR = _('Paths under .gecoosac-clear.* are reserved for upload cleanup.');
@@ -181,13 +180,26 @@ function usesManagedPath(value) {
(path !== null && (path.indexOf('/var/') === 0 || path.indexOf('/var/run/') === 0));
}
function uploadStorageRoot(path) {
if (path === DEFAULT_UPLOAD_DIR)
return '/tmp';
const segments = path === null ? [] : path.split('/');
if (segments.length === 5 && segments[1] === 'mnt' && segments[2] &&
segments[3] === 'gecoosac' && segments[4] === 'upload')
return '/mnt/' + segments[2];
return null;
}
function validUploadDir(value, policy) {
const path = normalizePath(value);
const physical = managedPath(value, policy);
const storageRoot = uploadStorageRoot(path);
const physicalStorageRoot = uploadStorageRoot(physical);
return path !== null && physical !== null && path.endsWith('/gecoosac/upload') &&
physical.endsWith('/gecoosac/upload') && !pathInDir(path, CONFIG_BACKUP_DIR) &&
!pathInDir(physical, CONFIG_BACKUP_DIR);
return !usesClearStagePath(value) && storageRoot !== null && storageRoot === physicalStorageRoot;
}
function validPathPrefix(value, prefixes) {
@@ -451,7 +463,7 @@ return view.extend({
httpsOption.onchange = revalidateProtocolOptions;
o = s.option(form.Value, 'upload_dir', _('Upload dir path'),
_('Upload AP upgrade firmware here. Use an absolute path ending with /gecoosac/upload, for example /tmp/gecoosac/upload.<br />Do not place it under /etc/gecoosac because that directory is backed up during sysupgrade.'));
_('Upload AP upgrade firmware here. Use /tmp/gecoosac/upload or /mnt/storage-name/gecoosac/upload. The /mnt/storage-name directory must already exist and be root-owned and private.'));
uploadDirOption = o;
o.placeholder = DEFAULT_UPLOAD_DIR;
o.default = DEFAULT_UPLOAD_DIR;
@@ -465,7 +477,7 @@ return view.extend({
return validUploadDir(value, pathPolicy)
? true
: _('Upload directory must be an absolute path ending with /gecoosac/upload and must not be under /etc/gecoosac.');
: _('Upload directory must be /tmp/gecoosac/upload or /mnt/storage-name/gecoosac/upload.');
};
o = s.option(form.Value, 'db_dir', _('Database dir path'),
+7 -9
View File
@@ -55,17 +55,15 @@ msgid "Upload dir path"
msgstr "上传目录"
msgid ""
"Upload AP upgrade firmware here. Use an absolute path ending with "
"/gecoosac/upload, for example /tmp/gecoosac/upload.<br />Do not place it "
"under /etc/gecoosac because that directory is backed up "
"during sysupgrade."
"Upload AP upgrade firmware here. Use /tmp/gecoosac/upload or /mnt/storage-"
"name/gecoosac/upload. The /mnt/storage-name directory must already exist "
"and be root-owned and private."
msgstr ""
"AP 升级固件会上传到此目录。请使用 /gecoosac/upload 结尾的绝对路径,例如 "
"/tmp/gecoosac/upload。<br />不要放在 /etc/gecoosac 下,因为该目录会在 "
"sysupgrade 时被备份。"
"AP 升级固件会上传到此目录。请使用 /tmp/gecoosac/upload 或 /mnt/storage-"
"name/gecoosac/upload。/mnt/storage-name 目录必须已存在、归 root 所有且权限安全。"
msgid "Upload directory must be an absolute path ending with /gecoosac/upload and must not be under /etc/gecoosac."
msgstr "上传目录必须是 /gecoosac/upload 结尾的绝对路径,且不能位于 /etc/gecoosac。"
msgid "Upload directory must be /tmp/gecoosac/upload or /mnt/storage-name/gecoosac/upload."
msgstr "上传目录必须是 /tmp/gecoosac/upload 或 /mnt/storage-name/gecoosac/upload。"
msgid "Database dir path"
msgstr "数据库目录"
+7 -9
View File
@@ -55,17 +55,15 @@ msgid "Upload dir path"
msgstr "上傳目錄"
msgid ""
"Upload AP upgrade firmware here. Use an absolute path ending with "
"/gecoosac/upload, for example /tmp/gecoosac/upload.<br />Do not place it "
"under /etc/gecoosac because that directory is backed up "
"during sysupgrade."
"Upload AP upgrade firmware here. Use /tmp/gecoosac/upload or /mnt/storage-"
"name/gecoosac/upload. The /mnt/storage-name directory must already exist "
"and be root-owned and private."
msgstr ""
"AP 升級韌體會上傳到此目錄。請使用 /gecoosac/upload 結尾的絕對路徑,例如 "
"/tmp/gecoosac/upload。<br />不要放在 /etc/gecoosac 下,因為該目錄會在 "
"sysupgrade 時被備份。"
"AP 升級韌體會上傳到此目錄。請使用 /tmp/gecoosac/upload 或 /mnt/storage-"
"name/gecoosac/upload。/mnt/storage-name 目錄必須已存在、由 root 擁有且權限安全。"
msgid "Upload directory must be an absolute path ending with /gecoosac/upload and must not be under /etc/gecoosac."
msgstr "上傳目錄必須是 /gecoosac/upload 結尾的絕對路徑,且不能位於 /etc/gecoosac。"
msgid "Upload directory must be /tmp/gecoosac/upload or /mnt/storage-name/gecoosac/upload."
msgstr "上傳目錄必須是 /tmp/gecoosac/upload 或 /mnt/storage-name/gecoosac/upload。"
msgid "Database dir path"
msgstr "資料庫目錄"
@@ -58,6 +58,55 @@ normalize_path() {
printf '%s\n' "$normalized"
}
path_has_clear_stage_component() {
local path="$1" part rest
path="$(normalize_path "$path")" || return 1
rest="${path#/}"
while [ -n "$rest" ]; do
part="${rest%%/*}"
if [ "$part" = "$rest" ]; then
rest=""
else
rest="${rest#*/}"
fi
case "$part" in
.gecoosac-clear.*) return 0 ;;
esac
done
return 1
}
path_uses_clear_stage() {
local path="$1" resolved
path_has_clear_stage_component "$path" && return 0
if [ -e "$path" ] || [ -L "$path" ]; then
resolved="$(readlink -f "$path" 2>/dev/null)" || return 1
path_has_clear_stage_component "$resolved" && return 0
fi
return 1
}
is_supported_upload_path() {
local path="$1" storage
path="$(normalize_path "$path")" || return 1
path_uses_clear_stage "$path" && return 1
[ "$path" = "$DEFAULT_UPLOAD_DIR" ] && return 0
case "$path" in
/mnt/*/gecoosac/upload)
storage="${path#/mnt/}"
storage="${storage%/gecoosac/upload}"
[ -n "$storage" ] && [ "${storage#*/}" = "$storage" ]
;;
*) return 1 ;;
esac
}
managed_dir_path() {
local role="$1" path anchor
@@ -106,16 +155,7 @@ safe_upload_path() {
local path
path="$(normalize_path "$1")" || return 1
case "$path" in
/etc/gecoosac|/etc/gecoosac/*) return 1 ;;
esac
[ "$path" = "/tmp/gecoosac/upload" ] && return 0
case "$path" in
*/gecoosac/upload) return 0 ;;
esac
return 1
is_supported_upload_path "$path"
}
path_in_dir() {
+1 -1
View File
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.8.1
PKG_RELEASE:=210
PKG_RELEASE:=211
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
@@ -103,30 +103,7 @@ config nodes 'myshunt'
config shunt_rules 'DirectGame'
option remarks 'DirectGame'
option network 'tcp,udp'
option domain_list '# steam直连域名获取国内CDN走国内线路下载
cm.steampowered.com
steamserver.net
# steam国内CDN华为云
steampipe.steamcontent.tnkjmec.com
# steam国内CDN白山云
st.dl.eccdnx.com
st.dl.bscstorage.net
st.dl.pinyuncloud.com
# steam国内CDN新流云(原金山云)(支持ipv6)
dl.steam.clngaa.com
# steam国内CDN网宿
cdn.mileweb.cs.steampowered.com.8686c.com
cdn-ws.content.steamchina.com
# steam国内CDN腾讯云 (蒸汽中国独占)
cdn-qc.content.steamchina.com
# steam国内CDN阿里云(支持ipv6)
cdn-ali.content.steamchina.com
xz.pphimalayanrt.com
lv.queniujq.cn
alibaba.cdn.steampipe.steamcontent.com
# 国内游戏geosite域名
option domain_list '# 国内游戏geosite域名
geosite:category-games@cn'
option ip_list '# steam直连IP
45.121.184.0/24
@@ -196,6 +173,9 @@ steamcloud-sydney.storage.googleapis.com
steamcloud-taiwan.storage.googleapis.com
steamcloud-eu.storage.googleapis.com
domain:blizzard.com
#Steam支付
domain:checkout.steampowered.com
domain:globalapi.smart2pay.com
geosite:category-games'
config shunt_rules 'AIGC'
+1 -1
View File
@@ -17,7 +17,7 @@ LUCI_NAME:=luci-theme-footstrap
FOOTSTRAP_VERSION?=
ifneq ($(FOOTSTRAP_VERSION),)
PKG_VERSION:=$(FOOTSTRAP_VERSION)
PKG_RELEASE:=8
PKG_RELEASE:=9
endif
LUCI_TITLE:=Footstrap Theme
@@ -281,9 +281,36 @@
white-space: normal;
/* overflow-wrap dropped — base/40-tables.css breaks every cell; the copy that stood here
* moved nothing against 267 matching cells. */
padding-inline-end: min(200px, 50%);
padding-inline-end: min(230px, 50%);
font-size: var(--fs-type); font-weight: var(--fs-weight); color: var(--fs-text);
}
/* ON A NARROW CARD THE VALUE TAKES ITS OWN LINE — a reserve cannot be made to fit there.
*
* The value is out of flow (the bar's ::after, pinned over its far edge), so the only thing
* keeping it off the label is that padding, and the two are sized by different things: the
* padding is a fraction of the CARD, the value is however wide LuCI's formatter made it.
* `16.66 GiB / 16.66 GiB (100%)` measures 218px in the mono face at --fs-type-xs, and 50% of a
* 380px phone card is 190 — so the figure printed straight through the end of the label:
* "Дисковое пространство330.54 MiB / 7.98 GiB (4%)", reported from a phone. Measured across the
* range: the reserve is at its 230px cap and clear of every value from a ~460px card up, and
* short of the longest ones below that.
*
* Below the threshold the label keeps the whole line and the bar drops by one --fs-type-xs
* line, which is the band the value then sits in — right-aligned, on its own line, above its
* own bar. It costs one line per row on a phone and cannot collide at any label length.
* `fs-view` rather than a container of its own: the Memory/Storage cards are already single
* column at 800px (the grid collapse above), so below 560px the card IS the view minus its
* gutters, and adding `container-type` to the card would size-contain a box the port tiles
* deliberately keep uncontained. */
@container fs-view (max-width: 560px) {
body[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .td:first-child {
padding-inline-end: 0;
}
body[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .cbi-progressbar {
margin-block-start: calc(var(--fs-type-xs) * var(--fs-leading));
}
}
/* bar cell — spans full width on row 2 */
body[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .td:nth-child(2) {
grid-column: 1 / -1; grid-row: 2;
@@ -1,4 +1,65 @@
@layer page {
/* THE FILTER BAR, which LuCI writes as a run of inline text.
*
* `tools/views.js` (LogreadBox, shared by System Log, Kernel Log and every third-party page
* built on it) emits two bare `<div style="margin-bottom:10px">`, each holding label / control
* pairs with nothing around a pair: `<label>Не</label><input type=checkbox><label>объект:</label>
* <select>…`. Inline flow then breaks wherever the line runs out, and on a phone that is between
* a label and the control it names — reported from a 390px screen, where the second `Не` sat
* alone at the end of one line, `уровень:` opened the next one without it, and `Макс. строк:`
* broke INSIDE the label, leaving `Макс.` on one line and `строк:` on the next with the field it
* belongs to.
*
* Flex with wrapping fixes both halves: a wrap can now only happen BETWEEN items, and a label is
* one unbreakable item, so a pair still separates at worst onto two lines instead of a label
* cutting in half. The fields shrink instead of forcing that: `min-width: 0` undoes the field
* box in theme/60-inputs.css for this row only.
*
* Keyed on the controls' OWN ids rather than on the page: `data-page` differs between the system
* and the kernel log, and a third-party LogreadBox has neither, while ui.js gives these two rows
* the same ids wherever the widget is rendered. The inline `margin: 0 5px` LuCI writes on the
* labels stays as the horizontal spacing — only the ROW gap is ours, so nothing here has to
* fight an inline style. */
div:has(> #logFacilitySelect),
div:has(> #logTextFilter) {
display: flex;
flex-wrap: wrap;
align-items: center;
row-gap: var(--fs-space-2);
}
div:has(> #logFacilitySelect) > label,
div:has(> #logTextFilter) > label { white-space: nowrap; }
#logFacilitySelect, #logSeveritySelect, #logTextFilter, #logMaxRows {
flex: 1 1 auto;
/* both halves are needed and they answer different layouts: `min-width: 0` lets a FLEX item
* shrink past its content, `max-width: 100%` clamps a GRID item to its track. A <select>
* carries the theme's own 210px field width, which a `minmax(0, 1fr)` track does not
* override — measured at 360px, where both selects hung 3px past the view. */
min-width: 0;
max-width: 100%;
}
/* the two numeric-ish fields keep a sane ceiling: a max-rows box as wide as the filter reads as
* a second search field. */
#logMaxRows { flex-basis: 6ch; max-width: 12ch; }
/* ON A PHONE, ONE FILTER PER LINE. Wrapping alone still fills each line greedily, so the second
* `Не` rode the end of the first line while the `уровень:` it inverts opened the next one. The
* first row is a fixed alternation of exactly two filters — invert label, checkbox, name label,
* control, twice — so a four-column grid puts one filter on each line.
*
* The SECOND row is deliberately left to the wrapping above. It holds two filters of different
* shapes (a checkbox filter and a bare `Макс. строк:` pair), so the same four tracks would be
* sized by items that are not each other's counterparts: measured, the max-rows box defined
* column 2 and pushed `содержит:` and its field off the card. Wrapping puts that row's two
* filters on two lines by itself, which is all the grid was for. */
@container fs-view (max-width: 560px) {
div:has(> #logFacilitySelect) {
display: grid;
grid-template-columns: auto auto auto minmax(0, 1fr);
column-gap: var(--fs-space-1);
}
}
/* System Log / Kernel Log (luci-mod-status): the whole log is a readonly
* <textarea id="syslog"> in a bare div, NOT in a .cbi-value-field. Stock bootstrap pairs
* its generic `input, textarea { width: 210px }` with `#syslog { width: 100% }` and wins
@@ -26,6 +26,33 @@
color: var(--fs-dim); white-space: nowrap; text-shadow: none;
}
/* THE BAR IS THE FIELD OF A LABELLED ROW — put the value BESIDE it, not above it.
*
* The placement above the bar (and the stock one it replaced: bootstrap floats the same title
* at `top: -1.4em; left: 0`) assumes the shape the Overview has, where the bar owns a full-width
* line and its name sits on the line above — there the value lands at the far end of the name's
* own line and the two read as one row. A `.cbi-value` row is the other shape: the name is
* already in the LEFT column, on the bar's own line, so a value floating above the bar has
* nothing to pair with. It lands against the row divider instead, nearer the row above than the
* bar it belongs to — reported against luci-app-modeminfo's Antennas page, three of these rows
* per card and every dBm figure reading as though it belonged to the meter above it.
*
* Inline, then: the row becomes `name | meter | value`, one line, values aligned to a single
* column because the reserve is fixed rather than shrink-wrapped. The reserve is
* `2 x --fs-space-10` because the value can be `-140.0 dBm`, half again as wide as the
* `100% (1.2 GiB)` a single step covers in the data-table rule below.
*
* Not the same selector as that rule and deliberately so: this is the CBI FORM row (an app
* building a `.cbi-value` with ui.js), that one is a multi-column data table. Both end up
* inline-right; neither can be expressed as the other, because CSS cannot ask "does this row
* carry its own label". The Overview and the package manager keep the value above the bar:
* their meters sit in a `.td` and a bare `.controls` div, neither of which is a field. */
.cbi-value-field > .cbi-progressbar { margin-inline-end: calc(var(--fs-space-10) * 2); }
.cbi-value-field > .cbi-progressbar::after {
inset-inline: calc(100% + var(--fs-space-2)) auto; bottom: auto; top: 50%;
transform: translateY(-50%);
}
/* Progressbar rows in a section table (luci-app-cpu-status): keep the native label | bar
* row, just centre the cells vertically. */
.cbi-section .table:not([id]):not(.cbi-section-table) .tr:has(.cbi-progressbar) .td { vertical-align: middle; }
@@ -249,6 +249,23 @@
* the thing the scale exists to stop. Absorbed with the machinery above. */
min-height: var(--fs-space-5);
}
/* …and the row's CONTENTS, which is where that font was being lost. A MULTI-select wraps every
* item in `<form><label><input type=checkbox> text</label></form>` (ui.js builds the checkbox
* that way), and `.cbi-value-field *` in theme/60-inputs.css hands mono to every descendant of a
* field — it matches that form and that label DIRECTLY, while the rule above only reaches the
* `<li>`, whose font the label then overrides rather than inherits. So the same widget drew two
* fonts depending on one option: a single-select list, whose text sits in the `<li>` itself,
* came out sans; every multi-select list came out mono, in the open menu AND in the closed
* control that lists what is selected. Measured on the router with ui.Dropdown: `<li>` Manrope,
* its `<form>`/`<label>` JetBrains Mono.
*
* It is a LABEL, not a value — the same distinction `<strong>` gets in theme/45-misc.css — and
* mono spacing is what makes a wordy option list look stretched (`3G Band 1`, `LTE B1`, reported
* against luci-app-modeminfo's Bands page). `inherit`, not the token again: the row above is the
* one place the menu's font is chosen. The filter/create `<input>` inside the list is left
* alone: what the user TYPES there is a value. */
.cbi-dropdown > ul > li > form,
.cbi-dropdown > ul > li label { font-family: inherit; }
.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li[display]:not([display="0"]) { border-inline-start: 0; }
.cbi-dropdown > ul > li[placeholder] { color: var(--fs-faint); }
/* the multiselect's inline filter/create inputs — moved from base with the generic `input`
@@ -348,6 +365,47 @@
background: var(--fs-panel2); color: var(--fs-accent); box-shadow: inset 2px 0 0 var(--fs-accent);
/* @endmirror */
}
/* THE FOCUSED ROW WEARS THE THEME'S RING, not the browser's.
*
* ui.js focuses a row as it opens the menu (the first selected one, or the first row), and the
* UA answers a programmatic focus with `outline: auto` — Chrome's 1px system-blue ring, drawn
* OUTSIDE the row and following its 8px radius, so opening a dropdown that already has a value
* flashed a loud blue capsule around the top row on every single-select and multi-select on the
* router. Every other control in this theme wears --fs-focus-ring; this row wore whatever the
* browser shipped.
*
* Inset rather than the token's outer 3px: the menu clips its own overflow, so an outer ring is
* cut on the first and last rows. The rail is restated INSIDE this ring so a focused row that is
* also selected keeps its rail — a shorthand cannot merge with the one below.
*
* Both selectors carry `[selected]`-level weight on purpose: the selected-row paint below is
* (0,4,1) and a bare `li:focus-visible` ties with it, which would leave the focus ring to source
* order. `docs/conventions.md`: win on specificity, never on order. */
.cbi-dropdown[open] > ul.dropdown > li[selected]:focus-visible,
.cbi-dropdown[open] > ul.dropdown > li:focus-visible {
outline: none;
box-shadow: inset 2px 0 0 var(--fs-accent), inset 0 0 0 2px var(--fs-accent-soft);
}
/* A MULTI-select row says "chosen" with its CHECKBOX, so it does not also need the rail.
*
* The rail is `inset 2px 0 0` on a row with an 8px radius, so it is drawn as an ARC, not a bar —
* fine as the single-select cue it was designed for, one row at a time, and wrong down a column
* of them: three chosen bands rendered three blue crescents stacked against the menu's edge and
* read as a broken frame (reported against luci-app-modeminfo's Bands page). The checkbox is a
* non-colour cue in its own right, which is what WCAG 2.2 SC 1.4.1 asks of "selected" — a
* single-select row has none, and keeps the rail.
*
* Written as an override rather than by narrowing the paint below, because those three
* declarations are pinned byte-identical to `select option:checked` in theme/60-inputs.css
* (tools/mirror.mjs) — a native `<select multiple>` option has no checkbox, so the rail is still
* the only cue there and the two must not diverge. `:not(:focus-visible)` keeps this off the
* focused row, so the ring above owns that state alone instead of tying with it. */
.cbi-dropdown[multiple][open] > ul.dropdown > li[selected]:not(:focus-visible) { box-shadow: none; }
.cbi-dropdown[multiple][open] > ul.dropdown > li[selected]:focus-visible {
box-shadow: inset 0 0 0 2px var(--fs-accent-soft);
}
.cbi-dropdown[open] > ul.dropdown > li.create-item { border-top: 1px solid var(--fs-border); margin-top: var(--fs-space-1); }
/* The dropdown's "add item" field is a text input and takes the same radius as every other
* one (theme/60-inputs.css). It said 8px here, and won or lost on source order. */