⛄ Sync 2026-08-12 20:59:19

This commit is contained in:
github-actions[bot] committed 2026-08-12 20:59:19 +08:00
1 parent d5f9df4a19
commit 5c244874df
62 files changed
+2165 -809

No files matched your search

+2 -2
View File
@@ -10,8 +10,8 @@ include $(TOPDIR)/rules.mk
PKG_ARCH_BAIDUDRIVE:=$(ARCH)
PKG_NAME:=baidudrive
PKG_VERSION:=baidudrive-runtime-v1.0.5
PKG_RELEASE:=8
PKG_VERSION:=linkeasefull-runtime-v3.0.6
PKG_RELEASE:=9
PKG_SOURCE:=$(PKG_NAME)-binary-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/baidudrive-runtime-v$(PKG_VERSION)/
PKG_HASH:=skip
+2 -2
View File
@@ -12,7 +12,7 @@ PKG_ARCH_LINKEASE:=$(ARCH)
PKG_NAME:=linkease-common-bin
# use PKG_SOURCE_DATE instead of PKG_VERSION for compitable
PKG_SOURCE_DATE:=1.7.5
PKG_RELEASE:=4
PKG_RELEASE:=5
ARCH_HEXCODE:=
ifeq ($(ARCH),x86_64)
@@ -28,7 +28,7 @@ else ifeq ($(ARCH),mipsel)
ARCH_HEXCODE=1b0c
endif
PKG_SOURCE_VERSION:=b5383a43aa18185cc32c759d2b8536c235fe54aa
PKG_SOURCE_VERSION:=9f351503d4320bb3767c300bc677a85c5685c9ba
PKG_SOURCE:=linkease-common-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE).tar.gz
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkease-runtime-v$(PKG_SOURCE_DATE)/
PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-common-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE)
+2 -2
View File
@@ -12,7 +12,7 @@ PKG_ARCH_LINKEASE:=$(ARCH)
PKG_NAME:=linkease
# use PKG_SOURCE_DATE instead of PKG_VERSION for compitable
PKG_SOURCE_DATE:=1.7.5
PKG_RELEASE:=7
PKG_RELEASE:=8
ARCH_HEXCODE:=
ifeq ($(ARCH),x86_64)
@@ -28,7 +28,7 @@ else ifeq ($(ARCH),mipsel)
ARCH_HEXCODE=1b0c
endif
PKG_SOURCE_VERSION:=b5383a43aa18185cc32c759d2b8536c235fe54aa
PKG_SOURCE_VERSION:=9f351503d4320bb3767c300bc677a85c5685c9ba
PKG_SOURCE:=linkease-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE).tar.gz
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkease-runtime-v$(PKG_SOURCE_DATE)/
PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE)
+2 -2
View File
@@ -11,7 +11,7 @@ PKG_ARCH_LINKEASE:=$(ARCH)
PKG_NAME:=linkeasefull
# use PKG_SOURCE_DATE instead of PKG_VERSION for compitable
PKG_SOURCE_DATE:=3.0.5
PKG_RELEASE:=6
PKG_RELEASE:=7
ARCH_HEXCODE:=
ifeq ($(ARCH),x86_64)
@@ -24,7 +24,7 @@ LINKEASE_RUNTIME_ARCH:=arm64
PKG_HASH:=skip
endif
PKG_SOURCE_VERSION:=b5383a43aa18185cc32c759d2b8536c235fe54aa
PKG_SOURCE_VERSION:=9f351503d4320bb3767c300bc677a85c5685c9ba
PKG_SOURCE:=linkease-runtime-$(PKG_SOURCE_DATE)-linux-$(LINKEASE_RUNTIME_ARCH).tar.gz
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkeasefull-runtime-v$(PKG_SOURCE_DATE)/
PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-runtime-$(PKG_SOURCE_DATE)-linux-$(LINKEASE_RUNTIME_ARCH)
+1 -1
View File
@@ -15,7 +15,7 @@ LUCI_TITLE:=Nezha Agent is the client version 2 of the Nezha Panel.
LUCI_DEPENDS:=+nezha-agentv1
LUCI_PKGARCH:=all
PKG_VERSION:=30.0
PKG_RELEASE:=1
PKG_RELEASE:=2
PKG_MAINTAINER:=<https://github.com/2018nuoyan/openwrt_nezha/>
include $(TOPDIR)/feeds/luci/luci.mk
@@ -1,11 +1,14 @@
module("luci.controller.nezha-agent", package.seeall)
function index()
if not nixio.fs.access("/etc/config/nezha-agent") then
return
end
entry({"admin", "services", "nezha-agent"}, cbi("nezha-agent"), _("Nezha Agent"), 300).dependent = true
entry({"admin","services","nezha-agent","status"},call("act_status")).leaf=true
if not nixio.fs.access("/etc/config/nezha-agent") then
return
end
local page = entry({"admin", "services", "nezha-agent"}, cbi("nezha-agent"), nil, 300)
page.dependent = true
page.hidden = true
page.index = false -- 不在菜单索引中显示
entry({"admin","services","nezha-agent","status"},call("act_status")).leaf=true
end
function trim(s)
+666 -18
View File
@@ -1,26 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (c) 2025-2026 10000ge10000
Project: luci-app-openclaw — OpenClaw AI Gateway LuCI Plugin for OpenWrt
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Preamble
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
The full text of the GNU General Public License version 3 is available at:
https://www.gnu.org/licenses/gpl-3.0.html
SOFTWARE.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
This project integrates with OpenClaw (https://github.com/nicepkg/openclaw),
which is licensed under its own terms. Node.js runtime downloaded during setup
is subject to the Node.js license (https://github.com/nodejs/node/blob/main/LICENSE).
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+1 -1
View File
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-openclaw
PKG_VERSION:=$(strip $(shell cat $(CURDIR)/VERSION 2>/dev/null || echo "1.0.0"))
PKG_RELEASE:=23
PKG_RELEASE:=24
PKG_MAINTAINER:=10000ge10000 <10000ge10000@users.noreply.github.com>
PKG_LICENSE:=GPL-3.0
+2 -2
View File
@@ -7,8 +7,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.8.11
PKG_RELEASE:=214
PKG_VERSION:=26.8.12
PKG_RELEASE:=215
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
@@ -1246,7 +1246,7 @@ function add_shunt_rule()
if add_name then
local has = uci:get(appname, uid)
if has then
http_write_json_error({ message = "This ID already exists." })
http_write_json_error({ message = i18n.translate("This ID already exists.") })
return
end
else
@@ -245,11 +245,12 @@ table td, .table .td {
btn.disabled = false;
}
if (x && x.status === 200) {
origin_group_shunt_rule_order[group] = get_shunt_rule_order(group);
// origin_group_shunt_rule_order[group] = get_shunt_rule_order(group);
alert("<%:Saved current page order successfully.%>");
if (btn) {
btn.style.display = "none";
}
// if (btn) {
// btn.style.display = "none";
// }
window.location.reload();
} else {
alert("<%:Save failed!%>");
}
+3
View File
@@ -1142,6 +1142,9 @@ msgstr "分流规则组"
msgid "Please note attention to the priority, the higher the order, the higher the priority."
msgstr "请注意优先级问题,排序越上面优先级越高。"
msgid "This ID already exists."
msgstr "该 ID 已存在。"
msgid "Update..."
msgstr "更新中"
@@ -46,6 +46,8 @@ FWI=$(uci -q get firewall.passwall.path 2>/dev/null)
FAKE_IP="198.18.0.0/15"
FAKE_IP_6="fc00::/18"
USE_GEOVIEW=0
factor() {
if [ -z "$1" ] || [ -z "$2" ]; then
echo ""
@@ -392,6 +394,28 @@ load_acl() {
shunt6_set_name="psw_${sid}_shunt6"
ipset -! create $shunt_set_name nethash maxelem 1048576 timeout 172800
ipset -! create $shunt6_set_name nethash family inet6 maxelem 1048576 timeout 172800
# 预加载分流规则 ip 到 ipset
local GEOIP_CODE=""
local shunt_ids=$(uci show $CONFIG | grep "=shunt_rules" | awk -F '.' '{print $2}' | awk -F '=' '{print $1}')
local shunt_group shunt_id
if [ "${use_shunt_tcp}" = "1" ]; then
shunt_group=$(config_n_get $tcp_node shunt_group)
elif [ "${use_shunt_udp}" = "1" ]; then
shunt_group=$(config_n_get $udp_node shunt_group)
fi
for shunt_id in $shunt_ids; do
[ "${shunt_group}" != "$(config_n_get ${shunt_id} group)" ] && continue
config_n_get $shunt_id ip_list | tr -s "\r\n" "\n" | grep -v "^#" | sed -e "/^$/d" | grep -E "(\.((2(5[0-5]|[0-4][0-9]))|[0-1]?[0-9]{1,2})){3}" | sed -e "s/^/add $shunt_set_name &/g" -e "s/$/ timeout 0/g" | ipset -! -R
config_n_get $shunt_id ip_list | tr -s "\r\n" "\n" | grep -v "^#" | sed -e "/^$/d" | grep -E "([A-Fa-f0-9]{1,4}::?){1,7}[A-Fa-f0-9]{1,4}" | sed -e "s/^/add $shunt6_set_name &/g" -e "s/$/ timeout 0/g" | ipset -! -R
[ "$USE_GEOVIEW" = "1" ] && {
local geoip_code=$(config_n_get $shunt_id ip_list | tr -s "\r\n" "\n" | sed -e "/^$/d" | grep -E "^geoip:" | grep -v "^geoip:private" | sed -E 's/^geoip:(.*)/\1/' | sed ':a;N;$!ba;s/\n/,/g')
[ -n "$geoip_code" ] && GEOIP_CODE="${GEOIP_CODE:+$GEOIP_CODE,}$geoip_code"
}
done
if [ -n "$GEOIP_CODE" ]; then
get_geoip $GEOIP_CODE ipv4 | grep -E "(\.((2(5[0-5]|[0-4][0-9]))|[0-1]?[0-9]{1,2})){3}" | sed -e "s/^/add $shunt_set_name &/g" -e "s/$/ timeout 0/g" | ipset -! -R
get_geoip $GEOIP_CODE ipv6 | grep -E "([A-Fa-f0-9]{1,4}::?){1,7}[A-Fa-f0-9]{1,4}" | sed -e "s/^/add $shunt6_set_name &/g" -e "s/$/ timeout 0/g" | ipset -! -R
fi
}
}
[ -n "${dns_redirect_port}" ] && dns_redirect=${dns_redirect_port}
@@ -891,7 +915,7 @@ add_firewall_rule() {
local USE_BLOCK_LIST_ALL=${USE_BLOCK_LIST}
local _TCP_NODE=$(config_t_get global tcp_node)
local _UDP_NODE=$(config_t_get global udp_node)
local USE_GEOVIEW=$(config_t_get global_rules enable_geoview)
USE_GEOVIEW=$(config_t_get global_rules enable_geoview)
[ -z "$(first_type $(config_t_get global_app geoview_file) geoview)" ] && USE_GEOVIEW=0
[ -n "$_TCP_NODE" ] && [ "$(config_n_get $_TCP_NODE protocol)" = "_shunt" ] && USE_SHUNT_TCP=1 && USE_SHUNT_NODE=1
@@ -957,7 +981,7 @@ add_firewall_rule() {
[ "$USE_SHUNT_NODE" = "1" ] && {
local GEOIP_CODE=""
local shunt_ids=$(uci show $CONFIG | grep "=shunt_rules" | awk -F '.' '{print $2}' | awk -F '=' '{print $1}')
local shunt_group
local shunt_group shunt_id
if [ "${USE_SHUNT_TCP}" = "1" ]; then
shunt_group=$(config_n_get $_TCP_NODE shunt_group)
elif [ "${USE_SHUNT_UDP}" = "1" ]; then
@@ -52,6 +52,8 @@ FWI=$(uci -q get firewall.passwall.path 2>/dev/null)
FAKE_IP="198.18.0.0/15"
FAKE_IP_6="fc00::/18"
USE_GEOVIEW=0
factor() {
local ports="$1"
if [ -z "$1" ] || [ -z "$2" ] || [ "$ports" = "1:65535" ]; then
@@ -453,6 +455,28 @@ load_acl() {
gen_nftset $shunt_set_name_static ipv4_addr 0
gen_nftset $shunt6_set_name ipv6_addr "2d"
gen_nftset $shunt6_set_name_static ipv6_addr 0
# 预加载分流规则 ip 到 nftset
local GEOIP_CODE=""
local shunt_ids=$(uci show $CONFIG | grep "=shunt_rules" | awk -F '.' '{print $2}' | awk -F '=' '{print $1}')
local shunt_group shunt_id
if [ "${use_shunt_tcp}" = "1" ]; then
shunt_group=$(config_n_get $tcp_node shunt_group)
elif [ "${use_shunt_udp}" = "1" ]; then
shunt_group=$(config_n_get $udp_node shunt_group)
fi
for shunt_id in $shunt_ids; do
[ "${shunt_group}" != "$(config_n_get ${shunt_id} group)" ] && continue
config_n_get $shunt_id ip_list | sed 's/#.*//' | grep -E "(\.((2(5[0-5]|[0-4][0-9]))|[0-1]?[0-9]{1,2})){3}" | insert_nftset $shunt_set_name_static
config_n_get $shunt_id ip_list | sed 's/#.*//' | grep -E "([A-Fa-f0-9]{1,4}::?){1,7}[A-Fa-f0-9]{1,4}" | insert_nftset $shunt6_set_name_static
[ "$USE_GEOVIEW" = "1" ] && {
local geoip_code=$(config_n_get $shunt_id ip_list | tr -s "\r\n" "\n" | sed -e "/^$/d" | grep -E "^geoip:" | grep -v "^geoip:private" | sed -E 's/^geoip:(.*)/\1/' | sed ':a;N;$!ba;s/\n/,/g')
[ -n "$geoip_code" ] && GEOIP_CODE="${GEOIP_CODE:+$GEOIP_CODE,}$geoip_code"
}
done
if [ -n "$GEOIP_CODE" ]; then
get_geoip $GEOIP_CODE ipv4 | grep -E "(\.((2(5[0-5]|[0-4][0-9]))|[0-1]?[0-9]{1,2})){3}" | insert_nftset $shunt_set_name_static
get_geoip $GEOIP_CODE ipv6 | grep -E "([A-Fa-f0-9]{1,4}::?){1,7}[A-Fa-f0-9]{1,4}" | insert_nftset $shunt6_set_name_static
fi
}
}
[ -n "${dns_redirect_port}" ] && dns_redirect=${dns_redirect_port}
@@ -954,7 +978,8 @@ add_firewall_rule() {
if [ -f $RULES_PATH/chnroute.nft ] && [ -s $RULES_PATH/chnroute.nft ] && [ $(awk 'END{print NR}' $RULES_PATH/chnroute.nft) -ge 8 ]; then
nft -f $RULES_PATH/chnroute.nft
else
cat $RULES_PATH/chnroute | tr -s '\n' | sed 's/#.*//' | gen_nftset $NFTSET_CHN_STATIC ipv4_addr 0
gen_nftset $NFTSET_CHN_STATIC ipv4_addr 0
cat $RULES_PATH/chnroute | tr -s '\n' | sed 's/#.*//' | insert_nftset $NFTSET_CHN_STATIC
fi
gen_nftset $NFTSET_BLACK ipv4_addr "2d"
gen_nftset $NFTSET_BLACK_STATIC ipv4_addr 0
@@ -975,7 +1000,8 @@ add_firewall_rule() {
#echolog "使用缓存加载chnroute6..."
nft -f $RULES_PATH/chnroute6.nft
else
cat $RULES_PATH/chnroute6 | tr -s '\n' | sed 's/#.*//' | gen_nftset $NFTSET_CHN6_STATIC ipv6_addr 0
gen_nftset $NFTSET_CHN6_STATIC ipv6_addr 0
cat $RULES_PATH/chnroute6 | tr -s '\n' | sed 's/#.*//' | insert_nftset $NFTSET_CHN6_STATIC
fi
gen_nftset $NFTSET_BLACK6 ipv6_addr "2d"
gen_nftset $NFTSET_BLACK6_STATIC ipv6_addr 0
@@ -993,7 +1019,7 @@ add_firewall_rule() {
local USE_BLOCK_LIST_ALL=${USE_BLOCK_LIST}
local _TCP_NODE=$(config_t_get global tcp_node)
local _UDP_NODE=$(config_t_get global udp_node)
local USE_GEOVIEW=$(config_t_get global_rules enable_geoview)
USE_GEOVIEW=$(config_t_get global_rules enable_geoview)
[ -z "$(first_type $(config_t_get global_app geoview_file) geoview)" ] && USE_GEOVIEW=0
[ -n "$_TCP_NODE" ] && [ "$(config_n_get $_TCP_NODE protocol)" = "_shunt" ] && USE_SHUNT_TCP=1 && USE_SHUNT_NODE=1
@@ -1059,7 +1085,7 @@ add_firewall_rule() {
[ "$USE_SHUNT_NODE" = "1" ] && {
local GEOIP_CODE=""
local shunt_ids=$(uci show $CONFIG | grep "=shunt_rules" | awk -F '.' '{print $2}' | awk -F '=' '{print $1}')
local shunt_group
local shunt_group shunt_id
if [ "${USE_SHUNT_TCP}" = "1" ]; then
shunt_group=$(config_n_get $_TCP_NODE shunt_group)
elif [ "${USE_SHUNT_UDP}" = "1" ]; then
+10 -9
View File
@@ -17,15 +17,15 @@ LUCI_NAME:=luci-theme-footstrap
FOOTSTRAP_VERSION?=
ifneq ($(FOOTSTRAP_VERSION),)
PKG_VERSION:=$(FOOTSTRAP_VERSION)
PKG_RELEASE:=13
PKG_RELEASE:=14
endif
LUCI_TITLE:=Footstrap Theme
# +luci-base is the WHOLE list. The self-updater used to hard-require `curl`, which is NOT in
# OpenWrt's default package set (the base image ships uclient-fetch), so the update button
# failed on a stock router; it falls back to uclient-fetch rather than pull curl onto an 8 MB
# device for one API call. jsonfilter and sha256sum ARE in the base image. Do not add a
# runtime dep for a convenience tool — fall back instead.
# +luci-base is the WHOLE list, and keeping it that way is the constraint. Nothing this package
# ships fetches anything at run time any more (the self-updater is retired), so there is no second
# name to justify — and the rule that got it here stands: the one convenience tool that ever wanted
# a dep was `curl`, which is NOT in OpenWrt's default set, on an 8 MB device, for one API call. Fall
# back to what the base image has (uclient-fetch, jsonfilter, sha256sum) or do without.
LUCI_DEPENDS:=+luci-base
LUCI_PKGARCH:=all
# Else luci.mk defaults these to LuCI's own, and the package claims MAINTAINER "OpenWrt LuCI
@@ -159,7 +159,8 @@ endef
# build-css.sh reads it from $(CURDIR) and writes into the build tree. cat/awk only, no host
# toolchain: that is why it runs on an OpenWrt buildbot.
# 2. Stamp the git-derived version into the theme JS (fs-version.js — the string the Appearance tab
# shows). Skipped when PKG_VERSION is empty, leaving '0.0.0-dev'.
# shows). Unconditional: with PKG_VERSION empty it falls back to PKG_SRC_VERSION, which luci.mk
# always defines, so only an un-built source checkout keeps the literal '0.0.0-dev'.
#
# THE CATALOGUE IS luci.mk's BUSINESS, and nothing here compiles it. `po/` is the directory
# LUCI_LANGUAGES globs, so luci.mk emits one `luci-i18n-footstrap-<lang>` package per language the
@@ -190,9 +191,9 @@ define Build/Prepare/luci-theme-footstrap
$(SHELL) $(CURDIR)/mangle-tokens.sh \
$(PKG_BUILD_DIR)/htdocs/luci-static/footstrap/cascade.css \
$(CURDIR)/htdocs/luci-static/resources $(CURDIR)/ucode
# Templates are 58% comments and shipped them all. jsmin/terser strip the JS, build-css.sh the
# Templates are mostly comments and shipped them all. jsmin/terser strip the JS, build-css.sh the
# CSS; the .ut files were simply never included in that trade. Only `{# … #}` goes — see the
# script for why the ucode-code `/* … */` deliberately stays. -16 KB of 39, git keeps every word,
# script for why the ucode-code `/* … */` deliberately stays. -22 KB of 60, git keeps every word,
# and dev-sync.sh does NOT run this: a router you are debugging on keeps its comments.
$(SHELL) $(CURDIR)/strip-templates.sh $(PKG_BUILD_DIR)/ucode
# …and the same for the shell under root/ (71% and 95% comment lines). Whole-line `#` only.
+9 -3
View File
@@ -1,6 +1,6 @@
#!/bin/sh
# Build luci-theme-footstrap as an OpenWrt .apk via the SDK.
# The theme is noarch (CSS/JS/templates/fonts only), so the package installs on any
# The theme is noarch (CSS, JS and templates only — it ships no fonts), so the package installs on any
# router of that release whatever its CPU architecture.
#
# ./build-apk.sh # download SDK if needed, then build
@@ -13,9 +13,15 @@
# has never heard of owfeed — and this is the only way to find out that it does.
set -e
REL="${OPENWRT_RELEASE:-25.12.2}"
# The point release and the compiler are both baked into the SDK's filename, so this default goes
# stale twice over: downloads.openwrt.org keeps only the CURRENT point release of a branch (older
# ones move to archive.openwrt.org and the fetch 404s), and a toolchain bump changes gcc-14.3.0.
# Kept in step with owfeed.lock's `point:` for the 25.12 branch — that file is what the release
# actually builds against. Override for any other: OPENWRT_RELEASE=24.10.8 sh build-apk.sh, and
# OPENWRT_SDK_FILE=… when the compiler string differs too.
REL="${OPENWRT_RELEASE:-25.12.5}"
SDK_BASE="https://downloads.openwrt.org/releases/${REL}/targets/mediatek/filogic"
SDK_FILE="openwrt-sdk-${REL}-mediatek-filogic_gcc-14.3.0_musl.Linux-x86_64.tar.zst"
SDK_FILE="${OPENWRT_SDK_FILE:-openwrt-sdk-${REL}-mediatek-filogic_gcc-14.3.0_musl.Linux-x86_64.tar.zst}"
SDK_URL="$SDK_BASE/$SDK_FILE"
# MUST be a case-sensitive fs (ext4/…), NOT an NTFS/9p Windows mount.
BUILD_DIR="${BUILD_DIR:-/tmp/ow-footstrap-build}"
+1 -1
View File
@@ -7,7 +7,7 @@
# order within a dir. A later layer beats an earlier one whatever the specificity, so
# nothing needs !important to override base (declared in styles/00-header.css).
#
# Minified unless --dev: ~287 KB of source -> ~111 KB (-61%). uhttpd serves CSS with NO
# Minified unless --dev: ~468 KB of source -> ~136 KB (-71%). uhttpd serves CSS with NO
# gzip, so every byte is a wire byte. Comments and whitespace go; a selector or a
# declaration is never rewritten, which is why LuCI's csstidy stays off (it mangles
# :has()/color-mix()). Needs only cat/awk, so an OpenWrt buildbot can run it.
+20 -10
View File
@@ -22,9 +22,9 @@ scp -q "$D"/ucode/template/themes/$N/*.ut "$R":/usr/share/ucode/luci/templ
ssh "$R" "mkdir -p /usr/share/ucode/luci/template/themes/$N/partials"
scp -q "$D"/ucode/template/themes/$N/partials/*.ut "$R":/usr/share/ucode/luci/template/themes/$N/partials/
# shared static (cascade.css, fonts, logo). The doodle wallpapers are NOT here and not in the
# package: they are downloaded on demand from GitHub (fs-prefs.js installWallpaper), so the dev
# router deliberately starts without them — that is the state to test the download dialog in.
# shared static: cascade.css and the two logo files, which is all this directory holds. No
# wallpapers — the doodles this theme once downloaded from GitHub are gone, and nothing in the
# package fetches anything at run time; the Background axis paints an SVG the admin uploads.
scp -qr "$D"/htdocs/luci-static/$N/* "$R":/www/luci-static/$N/
# EVERY resource JS, by GLOB — never by name. This used to list the four files
@@ -41,17 +41,27 @@ tar -C "$D/htdocs/luci-static/resources" -cf - . | ssh "$R" "mkdir -p /www/luci-
# Build/Prepare) so the Footstrap tab shows a real version instead of "(dev)". The FILE NAME is part
# of the contract: FS_VERSION lives in fs-version.js and moving it means changing every sed.
FS_V="$(git -C "$D" describe --tags --always 2>/dev/null | sed 's/^v//')"
# if-form, not `[ -n ] && ssh`: under set -e a failed &&-list aborts the whole sync when git
# describe yields nothing (a copied tree without .git). expr refuses a tag with
# sed/shell-special characters rather than interpolating it.
# if-form, not `[ -n ] && ssh`: under `set -e` a bare `&&`-list whose LAST command fails is what
# aborts, so `[ -n "$FS_V" ] && ssh …` would take the sync down whenever git describe yielded
# nothing (a copied tree without .git). An `&&` used to guard an ASSIGNMENT is a different shape and
# is safe — stage.sh:41 and update-po.sh:39 rely on that. expr refuses a tag with sed/shell-special
# characters rather than interpolating it.
if [ -n "$FS_V" ] && expr "$FS_V" : '[0-9A-Za-z._-]*$' >/dev/null; then
ssh "$R" "sed -i \"s#const FS_VERSION *= *'[^']*'#const FS_VERSION = '$FS_V'#\" /www/luci-static/resources/fs-version.js"
fi
# The catalogue, which the PACKAGE compiles in Build/Prepare. po2lmo is a luci-base host tool,
# absent from a dev machine by default, so this is best-effort: without it the strings stay
# English and the sync still succeeds. (Build it once from the pinned luci commit: cc -o po2lmo
# po2lmo.c lib/lmo.c lib/plural_formula.c.) Same basename as the package — see the Makefile.
# The catalogue. Nothing in Build/Prepare compiles it, and the RELEASE does not come from luci.mk
# either: owfeed bundles the catalogues inside the theme package (owfeed.yml), so a dev router gets
# them from nowhere unless this sync makes them. po2lmo is a luci-base host tool, absent from a dev
# machine by default, so this is best-effort: without it the strings stay English and the sync still
# succeeds. (Build it once from the pinned luci checkout: make -C modules/luci-base/src po2lmo —
# plural_formula.c is GENERATED by contrib/lemon, so a bare cc over the .c files cannot work.)
#
# THE BASENAME IS `footstrap-theme`, matching owfeed.yml and NOT the po file's own name: lmo_load_
# catalog globs *.<lang>.lmo so any basename loads, and a v0.8.4 router still owns footstrap.ru.lmo
# through the old luci-i18n package, where the same path would be a file conflict. Sync under a
# different name and the dev router carries two archives for the same msgids, with glob order
# deciding which one the page shows.
if command -v po2lmo >/dev/null 2>&1; then
ssh "$R" "mkdir -p /usr/lib/lua/luci/i18n"
for po in "$D"/po/*/*.po; do
@@ -11,8 +11,8 @@
*
* WHERE IT LIVES. It used to be a popover hanging off a button in the chrome; it is now a TAB on
* System -> System (admin/system/system), beside General Settings / Logging / Time Synchronization
* / Language and Style — the page an admin already opens to set the things that are not network. The axes had outgrown a floating panel — eighteen of them, nine
* carrying a colour field, a swatch and a contrast readout — and a dialog that has to trap Tab,
* / Language and Style — the page an admin already opens to set the things that are not network. The axes had outgrown a floating panel — twenty-one of
* them, nine carrying a colour field, a swatch and a contrast readout — and a dialog that has to trap Tab,
* place itself against a viewport edge and stay inside a 320px column is the wrong container for
* that. Keeping BOTH would have meant every axis rendered twice, which is the failure this file's
* own history is made of.
@@ -46,9 +46,12 @@
* the current look to the ROUTER for other browsers. That distinction is the whole model
* (docs/design-system.md) and it is why this page has no Save/Reset footer of LuCI's own. */
function render() {
/* still a promise: the view awaits it, and keeping the shape means a future asynchronous step
* (or the caller) needs no change. */
return Promise.resolve(build());
/* A promise, and the build runs INSIDE it rather than as its argument: `Promise.resolve(build())`
* evaluates build() synchronously, so a throw in it unwound out of render() before mount() could
* attach .catch/.finally — leaving mount()'s _building flag set for the life of the document. The
* tab then never built again on that page and nothing was logged, which is both channels silent
* at once. Deferred like this, the same throw lands in the .catch that exists for it. */
return Promise.resolve().then(build);
}
function build() {
@@ -219,15 +222,15 @@ function build() {
/* the STRENGTH half of the Tint — how strong the hue reads. Only meaningful in hue mode: a
* hex canvas IS the colour asked for, with no chroma of ours to scale. CSS hides it in the
* other two states (no tint at all, or a hex one). */
/* NOT "Density": that is the UI-density segment above, and this string is both the visible
/* NOT "Density": that is the UI-density select above, and this string is both the visible
* caption AND the control's aria-label, so two rows would read "Density" and a screen reader
* would announce "Density, radio group" and "Density, slider" with nothing to tell them
* would announce "Density, combo box" and "Density, slider" with nothing to tell them
* apart. */
group(_('Tint strength', 'footstrap'),
(label) => sliderCtl(prefs.currentTintStrength(), 0, 200, bump(repaint(prefs.applyTintStrength)), label, {
step: 5,
fmt: (v) => v + '%'
}), { cls: 'fs-ap-tint fs-ap-density' }),
unit: '%'
}), { cls: 'fs-ap-tint fs-ap-tintstr' }),
/* recolours the accented CONTROLS (buttons/toggles/sliders/focus rings), not the canvas the
* way Tint does. Measured as TEXT on a card, which is the use that fails first: as a fill it
@@ -487,8 +490,21 @@ function build() {
const saveErr = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' });
/* the Save button IS the status: match -> disabled "Saved as default", diverged -> enabled
* "Save as default". Called after every axis change (via bump). */
* "Save as default". Called after every axis change (via bump).
*
* Unless this browser refuses storage, in which case the status would be a lie in both halves:
* nothing was written, so every current*() reads the ROUTER default back and the comparison is
* true however far the page has been dragged from it. Say what is actually true instead — the
* axes apply and are forgotten on reload — and leave the button enabled, because pushing this
* browser's look to the router is the one thing that still works here. */
function refreshSave() {
if (prefs.storageBroken()) {
saveBtn.disabled = false;
saveBtn.textContent = _('Save as default', 'footstrap');
saveErr.textContent = _('This browser is not storing preferences (site data is blocked), so a change here lasts until you reload. Saving as default still works and applies to every browser.', 'footstrap');
saveErr.hidden = false;
return;
}
const saved = prefs.matchesSavedDefault();
saveBtn.disabled = saved;
saveBtn.textContent = saved ? _('Saved as default', 'footstrap') : _('Save as default', 'footstrap');
@@ -615,8 +631,9 @@ function build() {
open = !open;
prefs.lsSet(key, open ? 'on' : 'off');
paint();
/* the colour controls read the COMPUTED cascade, and a hidden element computes nothing
* useful — so the readouts are filled when the group becomes visible, not before */
/* Refreshed on OPEN because the axes below it may have moved while it was collapsed —
* a palette switch or a preset changes what every readout says. Cheap and skipped
* while closed: nothing in that fold is on screen to be wrong. */
if (open) refreshColours();
});
paint();
@@ -637,10 +654,11 @@ function build() {
section(_('Defaults', 'footstrap'), defaults)
]);
/* The colour controls read the COMPUTED cascade, which needs them in the document — so the first
* fill cannot happen while the tree is still being assembled above. The view appends this
* element synchronously on return, so a microtask is late enough and early enough: late enough
* that the probe resolves, early enough that nothing has painted a blank field. */
/* The first fill, deferred one microtask so the tree above is finished being assembled. It does
* NOT wait for the form to be in the document, and does not need to: every value a readout shows
* comes back through widgets.probeColor(), which keeps its own hidden probe attached to <body>
* and resolves the cascade there — so this runs while the form is still detached (mount() appends
* it a microtask later) and still reads the live palette. */
Promise.resolve().then(refreshColours);
return page;
}
@@ -666,7 +684,9 @@ function takeReturn() {
return true;
} catch (e) { return false; }
}
const MARK = 'fs-ap'; /* the built form's own class; also how mount() knows it is already there */
const MARK = 'fs-ap';
/* how long the stock view gets to render its tabs before a missing group counts as a failure */
const TAB_DEADLINE = 5000; /* the built form's own class; also how mount() knows it is already there */
const TAB = 'fs-appearance'; /* the pane's data-tab, which ui.tabs' click handler matches on */
let _routeObserver = null, _viewObserver = null, _observedView = null, _building = false;
@@ -692,11 +712,31 @@ function stopWatch() {
* yet is a page still rendering, not a page without tabs.
*
* The flag and the sibling are the whole test, deliberately: the panes themselves are NOT required
* to be found here. On the stock System page they are not direct children of the marked element at
* all — the map nests them a level deeper — so a `:scope > .cbi-tabcontainer` check found none and
* the tab was never added, silently, on a page that plainly has tabs. */
* to be found here, and a check for them by CLASS is what failed. A modern pane carries no class at
* all — form.js gives it `data-tab` and `data-tab-title` and nothing else, and `.cbi-tabcontainer`
* is luci-compat vocabulary from the Lua CBI — so `:scope > .cbi-tabcontainer` matched nothing on a
* page that plainly has tabs, and the tab was never added, silently. ui.tabs itself marks
* `panes[0].parentNode`, so the panes ARE this element's children; they are simply not identifiable
* that way. */
/* Tab groups that belong to the page we just LEFT. The router stamps body[data-page] before the
* incoming view renders, and on a warm route #view still holds the outgoing page's DOM at that
* moment — so mount() found ITS tab strip and appended the whole Appearance form, plus a live,
* clickable "Footstrap" <li>, to another page's tabs. Measured arriving at System -> System from
* Network -> DHCP: two builds for one arrival, and for 66 ms on localhost (an RTT or more on a real
* router, since the window is the incoming view's load()) the tab sat on the DHCP strip and opened
* all 24 Appearance rows when clicked. Every group present at the moment of the stamp is therefore
* disqualified; the incoming view's own group is a fresh element and is not in this set. */
const _staleGroups = new WeakSet();
function disqualifyCurrentGroups() {
const view = document.getElementById('view');
if (!view) return;
for (const g of view.querySelectorAll('[data-initialized="true"]'))
_staleGroups.add(g);
}
function tabGroup(view) {
for (const g of view.querySelectorAll('[data-initialized="true"]')) {
if (_staleGroups.has(g)) continue;
const menu = g.previousElementSibling;
if (menu?.classList.contains('cbi-tabmenu'))
return { group: g, menu };
@@ -739,8 +779,10 @@ function mount() {
const title = 'Footstrap';
/* data-tab-active is deliberately absent: the stock page opens on whichever tab it
* opened on before, and a theme has no business taking that over. */
/* The same shape a stock pane has, which is `data-tab` + `data-tab-title` and no class:
* ui.tabs.switchTab reads the attributes, and the `cbi-tabcontainer` class this used to
* carry is luci-compat's, styled by no rule this theme ships. */
t.group.appendChild(E('div', {
'class': 'cbi-tabcontainer',
'data-tab': TAB,
'data-tab-title': title
}, [ form ]));
@@ -764,13 +806,32 @@ function watch() {
_viewObserver = new MutationObserver(mount);
_viewObserver.observe(view, { childList: true, subtree: true });
mount();
/* A DEADLINE on the one failure that is otherwise perfectly silent. tabGroup() reads three
* private ui.tabs facts — the `data-initialized` marker, the `cbi-tabmenu` class on the menu it
* inserts, and `cbi-tab-disabled` on the items — and one of those has already moved between
* 24.10 and 25.12 (`data-tab-group` was dropped with no announcement). If any of the three we do
* read goes the same way, mount() simply returns early on every mutation: the stock page renders
* perfectly, nothing throws, and every Appearance axis becomes unreachable. Say it once, after
* the page has had time to render. */
window.setTimeout(() => {
const v = document.getElementById('view');
if (!onPage() || !v || v.querySelector('.' + MARK) || _building) return;
console.error('footstrap: the Appearance tab could not be attached — this page has tabs, but '
+ 'ui.tabs no longer marks them the way fs-appearance.js looks for. Every Appearance axis '
+ 'is unreachable until that is updated.');
}, TAB_DEADLINE);
}
/* Called by menu-footstrap-common's init, once. Everything route-dependent hangs off the data-page
* observer inside. */
function wire() {
if (_routeObserver || !document.body) return;
_routeObserver = new MutationObserver(() => (onPage() ? watch() : stopWatch()));
_routeObserver = new MutationObserver(() => {
/* BEFORE deciding anything: whatever is in #view at the moment data-page changes belongs to
* the page being left (see _staleGroups). */
disqualifyCurrentGroups();
return onPage() ? watch() : stopWatch();
});
_routeObserver.observe(document.body, { attributes: true, attributeFilter: [ 'data-page' ] });
if (onPage()) watch();
}
@@ -151,7 +151,7 @@ function resolveLen(token, dflt) {
* stylesheet's widths in two different places. Reaching for these means the measurement failed. */
const GEOM_DFLT = { contentMin: 500, sidebarW: 224, railW: 68, contentPad: 56 };
let _geom = null, _geomDensity = null;
let _geom = null, _geomDensity = null, _geomWarned = false;
function shellGeometry() {
const density = document.documentElement.getAttribute('data-density') || '';
if (_geom && _geomDensity === density) return _geom;
@@ -170,7 +170,19 @@ function shellGeometry() {
* absent token reports 0 for all four (an abs-positioned empty div with `width:auto` shrinks to
* 0, and 0 is finite, so the per-read fallback above never fires). Neither can be seen in the
* numbers one at a time; the RELATION between them is what gives it away. */
_geom = (g.railW > 0 && g.railW < g.sidebarW && g.contentMin > 0) ? g : Object.assign({}, GEOM_DFLT);
const sane = (g.railW > 0 && g.railW < g.sidebarW && g.contentMin > 0);
/* SAY SO when it fires. The fallback keeps the chrome laid out, which is right — but it lays it
* out on the literals CLAUDE.md forbids copying into JS, so from there on the sidebar folds at a
* width nobody chose while the page still LOOKS correct. Silent, that is how a renamed token
* ships green; one console line is the difference between a mystery and a grep. Once per
* document — this runs on every resize and every mutation. */
if (!sane && !_geomWarned) {
_geomWarned = true;
console.error('footstrap: the chrome widths did not read back from the stylesheet (got '
+ JSON.stringify(g) + ') — falling back to the built-in defaults. A --fs-* width token was '
+ 'renamed, or a foreign sheet is reaching the measurement probe.');
}
_geom = sane ? g : Object.assign({}, GEOM_DFLT);
return _geom;
}
@@ -236,8 +248,8 @@ function fitChrome() {
}
/* The cluster's own escalation, for EVERY bar — the top layout at any width, and the sidebar
* layout once fitShell has stamped data-narrow. It runs after the menu's, because on the top
* layout .fs-bar-stack has by then given the menu its own row and taken it out of the sum. */
* layout once fitShell has stamped data-narrow. It runs after the menu's, so that when the menu
* did NOT fit, .fs-bar-stack has already given it a row of its own. */
if (bar && (topBar || document.documentElement.hasAttribute('data-narrow')))
fitCluster(bar, menu);
}
@@ -268,8 +280,12 @@ function fitCluster(bar, menu) {
/* Add the widths up rather than read positions: the bar is align-items:center over children of
* differing heights, so offsetTop differs even on ONE row — the same trap the menu measurement
* documents above. The menu is excluded because it owns a full-width row of its own in every bar
* (`ul.nav { flex: 1 1 100% }`), so it is never what the cluster competes with. */
* documents above. The menu is excluded either way. Where it wraps to a row of its own it plainly
* is not competing (`ul.nav { flex: 1 1 100% }` — the sidebar-layout bar, and the top bar once
* .fs-bar-stack is set); where it does share the brand's row, un-stacked at `flex: 1 1 auto`
* (theme/50-toplayout.css), it is the one child that SHRINKS, so counting its current width would
* report the cluster as not fitting whenever the menu happened to be wide. What the cluster has to
* fit beside is the brand and the other actions. */
function clusterFitsBrandRow(bar, menu) {
const cs = getComputedStyle(bar);
const gap = parseFloat(cs.columnGap) || 0;
@@ -292,8 +308,7 @@ function clusterFitsBrandRow(bar, menu) {
* MutationObserver already watches — and it re-fits SYNCHRONOUSLY (rule 2), where the copy here
* deferred through fit.schedule(), i.e. the duplicate was strictly the slower path into the same
* work. #tabmenu is a sibling of #view rather than inside it, but nothing writes it except
* renderChrome(), which schedules a fit itself. Resize is fs-fit's ResizeObserver on #view (with a
* window-resize fallback where there is no RO). */
* renderChrome(), which schedules a fit itself. Resize is fs-fit's ResizeObserver on #view. */
/* modes -> #modemenu; drives the injected renderMainMenu for the active mode */
function renderModeMenu(node, renderMainMenu) {
@@ -44,13 +44,11 @@ function schedule() {
/* Watch an element's size. Any change re-fits everything — the fitters are cheap and few. */
function watch(el) {
if (!el) return;
if (!_ro) {
if (!window.ResizeObserver) { /* no RO: fall back to the window */
window.addEventListener('resize', schedule);
return;
}
_ro = new ResizeObserver(schedule);
}
/* No feature test: the shipped CSS needs :has() and container queries, both years younger than
* ResizeObserver in every engine, so a browser that can render this theme at all has it. The
* window-resize fallback that used to sit here was worse than nothing anyway — it cannot see a
* rail collapse or a layout toggle, which is the pair this file uses an observer FOR. */
if (!_ro) _ro = new ResizeObserver(schedule);
_ro.observe(el);
}
@@ -138,8 +138,8 @@ function readonlyForSegs(segs) {
}
/* The view class a menu node instantiates, or null if the node isn't SPA-able. The Status→Overview
* `template` node maps to view.status.index (its server template just instantiates that — see
* ensureOverviewHelpers in fs-router.js). Shared by navigate() and the hover prefetch. */
* `template` node maps to view.status.index (its server template just instantiates that — the
* globals that template also defines are fs-overview.js's, see ensureOverviewHelpers there). Shared by navigate() and the hover prefetch. */
function viewClassFor(node) {
if (!node || !node.action || node.satisfied === false)
return null;
@@ -19,15 +19,13 @@
* silenced it, but only after it had already been downloaded and run — a theme package reaching
* into another module's namespace, which is exactly what this project refuses to do to third-party
* apps (docs/conventions.md, the three zones). It is a chrome module now, loaded by the chrome, so a router on
* another theme never sees it at all.
* another theme never sees it at all — which is why nothing below re-checks `L.env.media`: the file
* cannot be reached except through this theme's own footer partial, and a gate for that is a gate
* for a state that cannot occur.
*
* WHAT THAT COST, and why the code below looks the way it does: the old location bought two timing
* guarantees for free, because LuCI evaluated the file INSIDE index.load(). Both had to be paid for
* explicitly — see ensureOverviewHelpers() and patchOverview() at the bottom. */
function isFootstrapTheme() {
return String(L.env.media || '').indexOf('footstrap') >= 0;
}
/* section title -> grid role. _() with NO msgctxt on purpose: these must resolve to exactly what
* luci-mod-status resolves to, or the titles stop matching. Built once — it used to cost an
* allocation plus three _() lookups per poll tick. */
@@ -39,7 +37,17 @@ function sectionTitle(sec) {
* grid never applied on 24.10 at all — measured on the dev container: every section tagged
* `none`, `.fs-ovl` never built, silently. */
const h = sec.querySelector('.cbi-title h3, :scope > h3');
return (h && h.firstChild) ? String(h.firstChild.nodeValue || '').trim() : '';
if (!h) return '';
/* The first non-empty TEXT node, not `firstChild`. That heading is not text-only any more: 25.12
* appends a hide/show <span> inside the same <h3>, and `firstChild` lands on the title today only
* because upstream happened to put the words first. One reordering there and every section reads
* as an empty title — the grid silently stops being built, with the page still rendering fine. */
for (const n of h.childNodes) {
if (n.nodeType !== 3) continue;
const t = String(n.nodeValue || '').trim();
if (t) return t;
}
return '';
}
/* the wrapper we built, so the poll-tick fast path costs one property read */
@@ -126,7 +134,7 @@ function watch() {
* The empty `.cbi-section` wrapper stock used to build around this include is gone with it, and so
* is the `.fs-ovl-marker` element that existed only to let CSS hide that wrapper. */
function wire() {
if (_routeObserver || !isFootstrapTheme() || !document.body)
if (_routeObserver || !document.body)
return;
_routeObserver = new MutationObserver(() => {
if ((document.body.getAttribute('data-page') || '') === 'admin-status-overview')
@@ -188,11 +196,21 @@ function fillSection(inc, container, res) {
}
let _inflight = null;
/* WHICH containers the in-flight run is filling. The guard below is module-level because the
* duplicate load it kills is module-level, but the frames are per RENDER — so joining a run blindly
* joined one that fills SOMEBODY ELSE'S frames. Reproduced: double-click Status → Overview 100 ms
* apart, or leave and return inside the first-load window, and the second arrival's sections stayed
* at their birth `display:none` for a full poll interval (measured 5.9 s against 0.4 s on a single
* arrival) — it had "joined" a run that was filling the frames the content swap had just detached.
* Stock LuCI has no such guard and each render fetches its own data, so the blank page is ours. */
let _inflightFor = null;
function pollProgressive(includes, containers, first_load) {
/* A run is already fetching exactly this data — join it instead of starting a second
* stampede of the same RPCs. This is what kills the duplicate load. */
if (_inflight)
/* A run is already fetching exactly this data, FOR THESE FRAMES — join it instead of starting a
* second stampede of the same RPCs. This is what kills the duplicate load. A run for older
* frames is left to finish into the detached nodes it owns, which costs nothing and is simpler
* than cancelling RPCs that are already in flight. */
if (_inflight && _inflightFor === containers)
return first_load ? Promise.resolve() : _inflight;
const run = network.flushCache().then(() => Promise.all(
@@ -214,7 +232,11 @@ function pollProgressive(includes, containers, first_load) {
if (ssi) { ssi.style.display = ''; ssi.classList.add('fade-in'); }
});
_inflight = run.finally(() => { _inflight = null; });
_inflight = run.finally(() => {
/* only if it is still OURS: a newer render may have replaced it while this one was running */
if (_inflightFor === containers) { _inflight = null; _inflightFor = null; }
});
_inflightFor = containers;
/* NOBODY AWAITS THIS ON THE FIRST LOAD — the line below hands the caller a fresh
* Promise.resolve() so index.render() can return at once — so a rejection here has no handler
* and surfaces as an unhandled rejection in the console. `run` rejects for one ordinary reason:
@@ -27,9 +27,19 @@
* tint back off). Every axis therefore records the chosen value, including the off/default one, the
* way `layout` always has. lsDel is reserved for resetToSaved(), which drops back to the router
* default on purpose. */
function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
function lsDel(k) { try { localStorage.removeItem(k); } catch (e) {} }
/* A browser can REFUSE storage outright — "block all cookies" for this address in Chrome/Safari,
* dom.storage.enabled=false in Firefox, a partitioned WebView — and then every access throws. The
* three helpers below still swallow it, because an axis that cannot be remembered must still be
* allowed to APPLY; what they no longer do is keep quiet about it. Without this flag the page told a
* flat lie: each control took effect, nothing was written, current*() then read null and fell back
* to the router default, so matchesSavedDefault() was true and the Save button sat there DISABLED
* reading "Saved as default" while the page was painted in three axes the router default does not
* carry — and a reload dropped all of them. The Appearance tab asks this and says so once. */
let _lsBroken = false;
function storageBroken() { return _lsBroken; }
function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { _lsBroken = true; return null; } }
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { _lsBroken = true; } }
function lsDel(k) { try { localStorage.removeItem(k); } catch (e) { _lsBroken = true; } }
/* A stored JSON ARRAY, or [] — the shape the two REMEMBERED LISTS use (the search palette's recent
* paths, the menu's open sections). lsGet above owns the try/catch around localStorage itself; this
@@ -45,7 +55,7 @@ function lsGetArr(k) {
}
/* the router-wide defaults the server stamped (head.ut). Read at RUNTIME so current*() reports the
* effective default when this browser has no localStorage — the popover's controls then show what
* effective default when this browser has no localStorage — the Appearance tab's controls then show what
* the page is actually painted as, not a phantom "auto". */
function sd(k) { try { return (window.__fsSD || {})[k]; } catch (e) { return undefined; } }
@@ -371,7 +381,7 @@ function applyPalette(val) {
* The list is what VALIDATES a stored value, so a value added to the CSS and not to this array is
* one head.ut pre-paints and the live applier then rejects: the page would paint it and the first
* touch of any other control would take it away. Adding one means this line, the head.ut whitelist,
* the segmented control in fs-appearance.js and the rules in 15-wallpaper.css.
* the Wallpaper select in fs-appearance.js and the rules in 15-wallpaper.css.
*
* A router upgrading from a version with the downloaded `cats`/`dinos` doodles reads its stored
* value here, finds it is not in the list, and falls to 'off' — the files those named are not in
@@ -576,8 +586,8 @@ function applyAutoCollapse(val) {
document.dispatchEvent(new CustomEvent('fs-autocollapse', { detail: { on } }));
}
/* The sidebar rail's collapsed flag. The BUTTON that flips it is chrome (fs-chrome.js): only the
* stored state belongs to the preference layer, and fs-update.js needs the ls* helpers anyway.
/* The sidebar rail's collapsed flag. The BUTTON that flips it is chrome (fs-chrome.js); only the
* stored state belongs to the preference layer.
* NOT part of the router-wide defaults — it is a transient chrome collapse, not an appearance
* choice, so it is absent from snapshotAxes()/resetToSaved() below. */
function applyRail(on) {
@@ -609,7 +619,10 @@ const AXIS_KEYS = [
/* Tint density: the STRENGTH of the router-identity Tint (the hue washed onto --fs-bg), a per-browser
* axis paired with the Tint hue — the hue picks the colour, this picks how strong it reads.
* --fs-tint-strength is a multiplier on the tint chroma (03-palettes.css): 100% = the designed
* strength, 0% = none, up to 200%. It only bites while a Tint hue is set (data-tint), and it is
* strength, up to 200%. 0 is not quite "no tint": the palette's canvas carries a slight cast of its
* own and the relative colour that applies the tint replaces chroma outright, so 0 leaves a neutral
* canvas at the same lightness rather than the untinted one (03-palettes.css measures it). Clearing
* the Tint hue is the real off. It only bites while a Tint hue is set (data-tint), and it is
* hidden and moot under the File wallpaper, where the tint resets to neutral (the photo covers the
* canvas). A normal per-browser axis: localStorage ?? router default ?? built-in; head.ut pre-paints
* it; stored explicitly (incl. the 100 default) so it can override a router default, like the hues.
@@ -700,7 +713,7 @@ function snapshotAxes() {
};
}
/* The RESOLVED router default (UCI value if set, else the built-in), in snapshotAxes() string form,
* so the popover can grey the Save button out when this browser already shows exactly it. Seeded
* so the Appearance tab can grey the Save button out when this browser already shows exactly it. Seeded
* from window.__fsSD at load and replaced with the just-saved snapshot after saveAsDefault(), so a
* save flips the match to true without a reload.
*
@@ -745,7 +758,7 @@ function matchesSavedDefault() {
return Object.keys(cur).every((k) => cur[k] === _savedDefault[k]);
}
/* ---- /etc/config/footstrap is written by Save-as-default AND BY NOTHING ELSE -------------------
/* ---- no AXIS reaches /etc/config/footstrap except through Save-as-default ----------------------
* EVERY axis is per-browser and reaches the router only through this button. Two of them used to
* write through the moment they changed — `wallpaper` on every pick, `photo_dim` on every drag —
* on the argument that the File photo is router-side, so "which wallpaper shows it" and "how dim"
@@ -789,10 +802,11 @@ function resetToSaved() {
/* The built-in defaults, written through the ordinary appliers so each one validates its own value
* and stamps :root the way it always does. Stated here rather than derived: a "default" is only a
* default because it is the value a bare :root paints, and the three that already have a named
* const (rounding, tint strength, photo dim) use it, so the numbers cannot drift from the CSS. */
* default because it is the value a bare :root paints, and the five that already have a named
* const (rounding, tint strength, photo dim, pattern size, pattern strength) use it, so the numbers
* cannot drift from the CSS. */
function resetToBuiltin() {
/* TOP, not sidebar: the bar is what a bare :root paints (header.ut stamps it when uci says
/* TOP, not sidebar: the bar is what a bare :root paints (head.ut stamps it when uci says
* nothing), so it is what "the theme as it ships" means. This said 'sidebar' after the default
* flipped and nothing caught it — the button quietly reset to a layout that is no longer the
* default, which is the one thing this button must not do. */
@@ -955,6 +969,7 @@ function uploadPattern(file) {
* as making every other device paint it, which is the wallpaper axis and Save-as-default. */
.then(() => _uciSet('footstrap', 'settings', { pattern: tok }))
.then(() => _uciCommit('footstrap'))
.catch((e) => _rollbackUpload(PAT_PATH, e))
.then(() => {
/* switch THIS browser onto it — the ordinary axis path, localStorage only */
applyWallpaper('pattern');
@@ -967,8 +982,7 @@ function uploadPattern(file) {
/* Remove: delete the file, blank the token (uci `set` to '', not delete — the scoped ACL grants
* set/commit only), clear the tile live. */
function removePattern() {
return _fileRemove(PAT_PATH)
.catch(() => null) /* already gone is success — still blank the token below */
return _removeServed(PAT_PATH)
.then(() => _uciSet('footstrap', 'settings', { pattern: '' }))
.then(() => _uciCommit('footstrap'))
.then(() => { _applyPattern(''); });
@@ -990,11 +1004,33 @@ const BG_SERVE = '/luci-static/footstrap/bg'; /* the uhttpd symlink to BG_PATH (
const BG_MAX_SIDE = 1920; /* cap the longest side — a router serves this off flash with no gzip, and 1080p covers the screens LuCI is actually admin'd from; still crisp full-screen, far fewer flash/wire bytes */
const BG_QUALITY = 0.9;
const BG_SRC_MAX = 25 * 1024 * 1024; /* refuse a source this big before decoding (decode-bomb guard) */
const _fileRemove = rpc.declare({ object: 'file', method: 'remove', params: [ 'path' ], reject: true });
/* NO `reject: true` here, unlike every other declare in this file, and that is the whole point: with
* it the caller gets an Error whose only account of WHY is a sentence with the ubus code inside it,
* so "the file was already gone" and "the router refused to delete it" arrive indistinguishable —
* and both callers below used to swallow the rejection whole and report success. Without it the
* promise resolves with the ubus status as a NUMBER (rpc.js hands back `msg.result[0]`), which is a
* signal this code can actually branch on. */
const _fileRemoveStatus = rpc.declare({ object: 'file', method: 'remove', params: [ 'path' ] });
/* Delete, treating "not found" as done. Anything else is a real refusal — a read-only or full
* overlay, an immutable flag, a path replaced by a non-empty directory — and it must NOT be reported
* as a removal: the file stays on flash and stays fetchable WITHOUT A SESSION through the /www
* symlink, which is precisely what an admin removing a background for privacy reasons believes they
* have just stopped. */
const UBUS_NOT_FOUND = 4;
function _removeServed(path) {
return _fileRemoveStatus(path).then((res) => {
const code = (typeof res === 'number') ? res : parseInt(res, 10);
if (code === 0 || code === UBUS_NOT_FOUND || isNaN(code)) return;
return Promise.reject(new Error(
_('The router refused to delete the file (ubus status %d).', 'footstrap').format(code)));
});
}
/* cgi-upload writes the file mode 0600, and uhttpd refuses to SERVE a file that is not
* world-readable (measured: 0600 -> 403, 0644 -> 200), so make it 0644 before it can be fetched. The
* rpcd ACL grants exec on exactly `/bin/chmod 644 /etc/footstrap/login-bg` — one fixed command, no
* argument the caller controls. */
* rpcd ACL grants exec on exactly two fixed commands — `/bin/chmod 644 /etc/footstrap/login-bg` and
* `/bin/chmod 644 /etc/footstrap/pattern.svg`, the two files this module uploads — with no argument
* the caller controls. */
const _fileExec = rpc.declare({ object: 'file', method: 'exec', params: [ 'command', 'params' ], reject: true });
/* …and the ubus status is only half of it: `file.exec` reports the COMMAND's exit status inside the
* payload, so a chmod that ran and failed still comes back as a successful call. Unchecked, the
@@ -1015,7 +1051,7 @@ function _chmodServeable(path) {
const BG_TOKEN_RE = /^[a-f0-9]{6,64}$/;
/* the token the server last saved (window.__fsSD.login_bg), validated to the same hex charset the
* head.ut sanitiser and pre-paint use — so the popover shows the current background and builds a
* head.ut sanitiser and pre-paint use — so the Appearance tab shows the current background and builds a
* cache-busted preview src. '' = none. */
function currentLoginBg() {
const t = sd('login_bg');
@@ -1041,10 +1077,10 @@ function _applyLoginBg(tok) {
* sits in — it escapes as an uncaught error and leaves the promise pending FOREVER. Two real ways
* out of `onload`: `getContext('2d')` answers null when the canvas cannot be backed (out of memory
* on a low-RAM box is the case this decodes a 25 MB source on), and drawImage/toBlob can throw on
* their own. The caller is fs-appearance's file picker, which disables "Choose image" and relabels
* their own. The caller is the Appearance tab's file picker, which disables "Choose image" and relabels
* it "Uploading…" before the call and restores both in a `.finally()` — so a pending promise means
* that button stays disabled and lying for the life of the page. The popover is built ONCE, in
* init(), so nothing short of a reload gets it back. */
* that button stays disabled and lying until the form is rebuilt, which mount() does only when the
* stock view re-renders: the next arrival at System -> System, not this one. */
function _downscale(file) {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
@@ -1069,6 +1105,23 @@ function _downscale(file) {
});
}
/* An upload that has landed but could not be RECORDED must not stay on the router. The two paths
* below write the file first (cgi-upload) and the token second (uci), and the second half can fail
* on its own: no `settings` section yet, a narrowed uci ACL, ubus busy. The page then showed the
* rpc error — honest as far as it went — while the image sat in /etc/footstrap at mode 0644 and was
* served to ANYONE at /luci-static/footstrap/bg, because the /www symlink does not depend on the
* token. Worse, Remove is hidden exactly when the token is empty, so the page offered no way to
* delete what it had just published. Roll the file back instead, and report the failure that
* started it — a rollback that itself fails is appended, because at that point the admin has to
* know the file is there. */
function _rollbackUpload(path, cause) {
return _removeServed(path).then(
() => Promise.reject(cause),
() => Promise.reject(new Error(String((cause && cause.message) || cause) + ' — '
+ _('the uploaded file could not be removed either; it is still on the router.', 'footstrap')))
);
}
/* Upload flow: validate -> canvas re-encode -> multipart POST to cgi-io's cgi-upload (the same
* endpoint L.ui.uploadFile uses; session carried in the `sessionid` FIELD, path in `filename`, bytes
* in `filedata`) -> take the md5 `checksum` from the JSON reply as the cache-bust token -> save it in
@@ -1100,6 +1153,7 @@ function uploadLoginBg(file) {
* here would re-point every other device's default from one admin's upload, silently. */
.then(() => _uciSet('footstrap', 'settings', { login_bg: tok }))
.then(() => _uciCommit('footstrap'))
.catch((e) => _rollbackUpload(BG_PATH, e))
.then(() => {
/* switch THIS browser to the photo — the ordinary axis path, localStorage only */
applyWallpaper('file');
@@ -1112,8 +1166,7 @@ function uploadLoginBg(file) {
/* Remove: delete the file, blank the token (uci `set` to '', not delete — the scoped ACL grants
* set/commit only), clear the background live. */
function removeLoginBg() {
return _fileRemove(BG_PATH)
.catch(() => null) /* already gone is success — still blank the token below */
return _removeServed(BG_PATH)
.then(() => _uciSet('footstrap', 'settings', { login_bg: '' }))
.then(() => _uciCommit('footstrap'))
.then(() => { _applyLoginBg(''); });
@@ -1122,7 +1175,7 @@ function removeLoginBg() {
return baseclass.extend({
/* the storage helpers */
lsGet, lsSet, lsDel, lsGetArr,
lsGet, lsSet, lsDel, lsGetArr, storageBroken,
currentMode, applyMode, guardDarkStamp,
currentPalette, applyPalette,
@@ -47,9 +47,34 @@ const _viewIntervals = (window.__fsViewIntervals || (window.__fsViewIntervals =
};
})();
function clearViewIntervals() {
const keep = (L.Poll && L.Poll.timer) || null;
/* `L.Poll.timer` is the id of LuCI's OWN 1 s tick, and it is private state — `add`/`remove`/
* `start`/`stop`/`active` are the documented surface, and upstream has already marked the whole
* `L.Poll` alias deprecated (`'require poll'` is its replacement, and neither 24.10 nor 25.12
* ships poll.js yet, so the alias is still the only way in). If that field ever goes, `keep`
* becomes null and this sweep would clear LuCI's tick along with the view's timers — every poll
* on every later page silently dead, from a rename we did not notice. So the missing field is
* not a null: it is a reason to do NOTHING, once, loudly. A view's leftover interval outliving
* its page costs a wasted RPC; killing the global tick costs the router's live data. */
/* Asked through the DOCUMENTED half first: active() says whether LuCI's tick is running at all,
* and `timer` is deleted by stop() — so an absent field is the ordinary "nothing to protect"
* case on a page with no pollers, not a sign that upstream moved it. The anomaly worth reporting
* is the pair disagreeing: a tick that is running while the id it runs on has no name we know.
* Then do NOTHING, once, loudly: a view's leftover interval outliving its page costs a wasted
* RPC, whereas clearing LuCI's own tick costs every live value on every later page. */
const running = (typeof L.Poll.active === 'function') ? L.Poll.active() : (L.Poll.timer != null);
if (running && L.Poll.timer == null) {
if (!_pollTimerWarned) {
_pollTimerWarned = true;
console.error('footstrap: LuCI is polling but L.Poll.timer is not readable — leaving view '
+ 'intervals alone rather than risking its tick. fs-router.js needs updating for this '
+ 'luci-base.');
}
return;
}
const keep = running ? L.Poll.timer : null;
_viewIntervals.forEach((id) => { if (id !== keep) window.clearInterval(id); });
}
let _pollTimerWarned = false;
let _wired = false;
/* The pathname whose view is CURRENTLY rendered — popstate compares against it to tell a real
@@ -64,7 +89,7 @@ let _navGen = 0;
* The two layouts scroll different elements: the sidebar layout pins .fs-shell to 100dvh and gives
* overflow-y to .fs-main (#maincontent), the top layout lets the document scroll. A browser restores
* an INNER scrollable region only across full loads, never on a same-document traversal — measured
* (docs/spa-router.md §2): Back opened the incoming page at 0, because the swap empties #view,
* (docs/spa-router.md, "Scroll"): Back opened the incoming page at 0, because the swap empties #view,
* scrollHeight collapses and the browser clamps scrollTop.
*
* The DOCUMENT scroller was left to the browser's own scrollRestoration ('auto') on the grounds that
@@ -116,28 +141,75 @@ function saveScroll() {
}
/* Put the scrollers back where the entry left them — but only once the incoming view has grown that
* much height (docs/spa-router.md §5: restoring before the content exists is clamped to 0 and reads as
* much height (docs/spa-router.md, "Scroll": restoring before the content exists is clamped to 0 and reads as
* "worked"). The view renders behind an RPC, so poll by frame; a newer navigation cancels via the
* generation, and a page that never reaches the old height again is simply left at the top. Each
* offset is waited for on its OWN scroller, so a layout switched between the two entries restores
* whichever half it can rather than blocking on the half that no longer scrolls. */
function restoreScroll(pos, gen) {
if (!pos || (!pos.win && !pos.main)) return;
let tries = 300; /* ~5 s at 60 fps — outlasts a slow RPC without polling forever */
/* A DEADLINE, not a frame count: this was 300 frames called "~5 s", which holds only at 60 Hz —
* the same budget is 10 s on a 30 Hz panel. Frames stay the tick (they are when a paint could
* have changed the height); time decides when to stop waiting for an RPC that is not coming. */
const until = Date.now() + 5000;
/* THE USER OUTRANKS THE SAVED POSITION. Waiting up to five seconds for a slow view to grow means
* the reader may have started using the page in the meantime — and jumping them somewhere else
* two seconds after they began reading is worse than opening at the top, which is what a full
* load does anyway. So any sign that the scroll is THEIRS cancels the restore for good.
*
* Two kinds of sign, because neither covers the other. The three input events are intent even
* when nothing moves yet (a wheel tick on a page too short to scroll, a touch that becomes a
* drag); `scroll` is the catch-all for everything they cannot see — a scrollbar drag, a
* trackpad fling, Find-in-page, an anchor jump, assistive tech. `scroll` also fires for OUR
* OWN writes, asynchronously, so a flag around the write would still be false by the time it
* arrives: the position we last wrote is remembered instead, and a scroll that lands exactly
* there is ours. Landing there by hand is indistinguishable and costs nothing — it is the
* position we were restoring to.
*
* Passive listeners: this must never sit in front of the scroll it is watching for. */
let cancelled = false, wroteWin = -1, wroteMain = -1;
const stop = () => { cancelled = true; off(); };
const onScroll = (ev) => {
const t = ev.target;
const now = (t === document || t === document.documentElement || t === document.body)
? Math.round(window.scrollY) : (t && t.scrollTop);
if (now === wroteWin || now === wroteMain) return; /* our own write coming back */
stop();
};
/* the keys that scroll, and only those: typing in a field must not cancel anything */
const SCROLL_KEYS = new Set([ 'PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown', ' ', 'Spacebar' ]);
const onKey = (ev) => { if (SCROLL_KEYS.has(ev.key)) stop(); };
const opts = { passive: true, capture: true };
function off() {
window.removeEventListener('wheel', stop, opts);
window.removeEventListener('touchstart', stop, opts);
window.removeEventListener('keydown', onKey, opts);
window.removeEventListener('scroll', onScroll, opts);
}
window.addEventListener('wheel', stop, opts);
window.addEventListener('touchstart', stop, opts);
window.addEventListener('keydown', onKey, opts);
/* capture, so the inner scroller (#maincontent in the sidebar layout) is seen too: `scroll`
* does not bubble from an element, but it does travel down the capture phase. */
window.addEventListener('scroll', onScroll, opts);
(function tick() {
if (gen !== _navGen || --tries < 0) return;
if (cancelled) return;
if (gen !== _navGen || Date.now() > until) { off(); return; }
const de = document.documentElement;
const sc = document.getElementById('maincontent');
let pending = false;
if (pos.main) {
if (sc && sc.scrollHeight - sc.clientHeight >= pos.main) sc.scrollTop = pos.main;
if (sc && sc.scrollHeight - sc.clientHeight >= pos.main) { wroteMain = pos.main; sc.scrollTop = pos.main; }
else pending = true;
}
if (pos.win) {
if (de.scrollHeight - de.clientHeight >= pos.win) window.scrollTo(0, pos.win);
if (de.scrollHeight - de.clientHeight >= pos.win) { wroteWin = pos.win; window.scrollTo(0, pos.win); }
else pending = true;
}
if (pending) requestAnimationFrame(tick);
else off();
})();
}
@@ -276,38 +348,43 @@ function pragmaDeps(src) {
return out;
}
/* SIX CLASS NAMES HAVE NO FILE, and fetching one is a guaranteed 404 in the user's console. luci.js
* seeds its class registry with them at load — `const classes = { baseclass: Class, dom: DOM,
* poll: Poll, request: Request, session: Session, view: View }` — so require() answers them from
* memory and never asks the network, while `ls /www/luci-static/resources` has no baseclass.js,
* dom.js, poll.js, request.js or session.js at all. Every view file's pragmas name `view` and
* `baseclass`, so a dependency walk hits this on its very first step: measured, warming the recents
* at idle put 404s for view.js and poll.js in the console of every page load, and the first hover
* added baseclass.js. A require() that 404s is noise this theme refuses to make, and the same
* standard applies here.
/* WHICH CLASS NAMES ARE WORTH A PREFETCH, and the answer is the DOTTED ones — that is the whole
* rule now, and it used to be a hardcoded list of the six names luci.js seeds its registry with
* (`baseclass`, `dom`, `poll`, `request`, `session`, `view`). Those have no file, so fetching one is
* a guaranteed 404 in the user's console — measured before the list existed: warming the recents at
* idle put 404s for view.js and poll.js into every page load, and the first hover added baseclass.js.
* The list worked, and it was a literal copied out of luci.js rather than a guess about it, but it
* was still a copy: a seventh built-in would cost one 404 per name per session until this file
* caught up with a release of somebody else's software.
*
* A future LuCI adding a seventh built-in would cost one 404 per session — `_prefetched` makes it
* at most one — and the list is a literal from luci.js, not a guess about it. */
const BUILTIN_CLASSES = new Set([ 'baseclass', 'dom', 'poll', 'request', 'session', 'view' ]);
/* Is this class already instantiated? require() attaches its singleton to the LuCI prototype
* (`ptr[parts[idx]] = instance`), so a SINGLE-segment name reads back as L[name] — which covers the
* libs a loaded page has already pulled in (ui, form, uci, rpc, fs, validation, and network/firewall
* once some network page has been open). Skipping them spends no request on a cache hit nobody needs.
* `instanceof L.Class` rather than a truthiness test: L.env, L.url and L.get are members too, and a
* dep whose name collided with one of those would otherwise be skipped without ever being loaded —
* which is why this cannot be the guard for the six above either: they are seeded as CONSTRUCTORS
* (and under other names — L.Poll, L.Request, L.Class), so no `instanceof` probe sees them. A DOTTED
* name (tools.network) is not attached unless its parent already exists, so it is simply fetched —
* and those are the ones the win comes from. */
* The shape of the names answers it without the copy. A LuCI class name is a path — `tools.widgets`
* is tools/widgets.js — so a name with no dot is either one of those six virtual classes or one of
* the flat libraries (`ui`, `form`, `network`, `uci`, `rpc`, `fs`, `validation`), and those are
* already loaded by the time any prefetch runs: this theme's own chrome requires `network` for the
* overview grid, which drags in firewall/uci/rpc/validation, and `ui` comes with the widgets.
* Measured on the stand from three different landing pages, including the lightest one there is
* (System -> Reboot): all eight flat libraries were already instances on arrival, and driving the
* prefetch walk over seven pages fetched 10 files, every one of them nested. So the flat half of the
* namespace is worth nothing to prefetch and can never be worth a 404 — declining it outright costs
* nothing measurable, needs no list, and a future built-in is covered before it ships.
*
* The dotted half is still asked properly: require() attaches a class at its path (`ptr[parts[i]] =
* instance`), so `tools.widgets` reads back as L.tools.widgets once some form page has pulled it,
* and a second navigation there spends no request at all. `instanceof L.Class` rather than a
* truthiness test: L.env, L.url and L.get are members too. */
function classLoaded(name) {
if (BUILTIN_CLASSES.has(name)) return true;
try { return name.indexOf('.') < 0 && window.L[name] instanceof window.L.Class; }
if (name.indexOf('.') < 0) return true;
try {
let ptr = window.L;
for (const part of name.split('.')) {
ptr = ptr[part];
if (ptr == null) return false;
}
return ptr instanceof window.L.Class;
}
catch (e) { return false; }
}
/* view classes already required, i.e. the ones LuCI has an instance cached for. A class NOT in
* here is rendered by the require() itself (see navigate). */
const _seen = new Set();
const _prefetched = new Set();
/* className -> the promise of ITS OWN body being in the HTTP cache; navigate() waits on that.
@@ -490,7 +567,7 @@ function navigate(pathname, push, kbd) {
* when View.__init__ replaces it there is nothing to see, and the string arrives already
* translated in the ~40 languages this theme ships no catalogue for (the chrome rule — a
* context-free msgid is how we inherit luci-base's translation). */
if (!_seen.has(tree.viewClassFor(node))) {
if (!_seen.has(className)) {
const vp = document.getElementById('view');
if (vp) {
const s = document.createElement('div');
@@ -541,7 +618,11 @@ function navigate(pathname, push, kbd) {
try { fn(rsegs); }
catch (e) { console.error('footstrap: a navigation callback threw', e); }
}
try { if (typeof ui.hideModal === 'function') ui.hideModal(); } catch (e) {}
/* ui hard-requires into this module and ui.js defines hideModal unconditionally, so there is no
* feature to test; what is caught is a modal's own teardown throwing, which must not take the
* navigation with it. Reported, not swallowed — the same rule as the loop above. */
try { ui.hideModal(); }
catch (e) { console.error('footstrap: hideModal threw during a navigation', e); }
/* point the runtime env at the new node so views, tabs and highlighting read the right
* path. For a fully-matched leaf, request == dispatch path. */
@@ -579,8 +660,8 @@ function navigate(pathname, push, kbd) {
* correctly returns — one dead Back press per stray click. A full load has no such trap. */
if (push) {
const same = pathname === window.location.pathname;
if (_curId == null) _curId = newEntryId();
/* a NEW entry gets a new id; re-navigating in place keeps the entry and therefore its id */
/* a NEW entry gets a new id; re-navigating in place keeps the entry and therefore its id
* (seed() adopted one before wire() made this function reachable, so there is always one) */
if (!same) _curId = newEntryId();
history[same ? 'replaceState' : 'pushState']({ fsnav: true, fsid: _curId }, '', pathname);
}
@@ -601,7 +682,7 @@ function navigate(pathname, push, kbd) {
*
* A popstate replay resets nothing on purpose: BOTH scrollers are restored there from _scrollMem —
* see restoreScroll(). scrollRestoration is left at 'auto': the UA's own attempt lands before the
* swap and is undone by it (§2), so it neither helps nor hurts, and 'manual' would only take away
* swap and is undone by it (see restoreScroll above), so it neither helps nor hurts, and 'manual' would only take away
* the case that does work — a genuine full load. */
if (push) {
window.scrollTo(0, 0);
@@ -613,7 +694,8 @@ function navigate(pathname, push, kbd) {
* renderChrome() has just done `#topmenu.innerHTML = ''`, so the very <a> the user activated with
* Enter no longer exists: focus falls back to <body>, the next Tab restarts at the skip link, and
* nothing says the page changed — URL, title and #view all moved in silence. So do what a real
* navigation would, and where matters (Sutton's five-prototype study, docs/spa-router.md §3): a KEYBOARD
* navigation would, and where matters (Sutton's five-prototype study, docs/spa-router.md,
* "Accessibility of a route change"): a KEYBOARD
* activation (ev.detail === 0) moves focus to the skip link — a small target whose :focus overlay
* tells a sighted keyboard user where they are, with Enter jumping straight to the content; its
* text differs from the live region's announcement below, so the double announcement complements
@@ -664,7 +746,15 @@ function navigate(pathname, push, kbd) {
* repairStaleRender() a mess that only exists because a require in flight cannot be stopped. */
if (gen !== _navGen) return null;
_seen.add(className);
return RT.require(className);
/* NAME THE OWNER for the length of this require. On a first visit the require IS the render,
* so any <style> the module injects belongs to THIS page — even if a newer navigation has
* stamped data-page by the time it lands, which is exactly the case repairStaleRender() below
* exists for. Without it fs-sheets credited such a sheet to the page that superseded this one
* and bound it there for the life of the document (measured: luci-app-filemanager's
* `.cbi-button-save { display: none !important }` disabled on its own page and live on
* System -> System, across return visits). */
sheets.attributeTo(rsegs);
return RT.require(className).finally(() => sheets.attributeTo(null));
}).then((view) => {
if (view == null) return;
if (!(view instanceof RT.view))
@@ -818,7 +908,8 @@ function wireRouter() {
* take back what that one just painted. */
document.addEventListener('poll-stop', () => {
if (L.Poll && L.Poll.queue && L.Poll.queue.length === 0) {
try { ui.hideIndicator('poll-status'); } catch (e) {}
try { ui.hideIndicator('poll-status'); }
catch (e) { console.error('footstrap: hideIndicator threw on poll-stop', e); }
}
});
@@ -844,7 +935,7 @@ function wireVisibility() {
else if (wasActive) {
L.Poll.start();
}
} catch (e) {}
} catch (e) { console.error('footstrap: the poll pause/resume threw', e); }
});
}
@@ -24,7 +24,11 @@
/* ---- the index ---------------------------------------------------------- */
/* admin/<section>/<page>/<tab> — four levels is every path LuCI's dispatcher renders. */
/* How deep below the MODE the walk goes. `depth` counts recursion levels and the walk starts at 1
* on a node that already has two segments, so a node at depth N carries N+1 of them: 4 admits
* admin/<section>/<page>/<tab>/<subtab>. That is one more than LuCI's own dispatcher renders, and
* deliberately so — a third-party node nested deeper is worth finding, and the depth term in the
* score below already ranks it last. */
const MAX_DEPTH = 4;
let _index = null;
@@ -99,6 +103,13 @@ function buildIndex() {
return out;
}
/* Built once per DOCUMENT, and deliberately not re-derived per open: it is a projection of the
* client menu tree, which `ui.menu.load()` itself caches for the life of the document. So the
* palette is exactly as fresh as the sidebar beside it — a package installed or removed without a
* reload is missing from, or still listed in, BOTH. Invalidating only this half would make the two
* disagree, which is worse than either being stale; the reload the package manager already prompts
* for is what refreshes them together. (Cheap enough to rebuild if that ever changes: 238 nodes on
* the dev router.) */
function index() {
if (!_index) _index = buildIndex();
return _index;
@@ -190,9 +201,9 @@ function warmRecent() {
const paths = _recent.filter((p) => p !== here).slice(0, RECENT_WARM);
if (!paths.length) return;
/* Nothing waits on this, so it belongs after the page has settled — at idle, with a timeout for a
* page that never goes idle (a busy poll). The fallback is deliberately a LONG timeout, unlike
* fs-appearance's ~1 ms one: that wires a button the user may click immediately, this competes
* with the view's own module fetches and RPCs and must lose that race on purpose. */
* page that never goes idle (a busy poll). The fallback is deliberately a LONG timeout: nothing
* on screen waits for this, so it competes with the view's own module fetches and RPCs and must
* lose that race on purpose. */
const go = () => paths.forEach((p) => router.prefetchSegs(p.split('/')));
if (typeof window.requestIdleCallback === 'function')
window.requestIdleCallback(go, { timeout: 4000 });
@@ -379,8 +390,7 @@ function wire() {
/* Back and Forward are navigations no listener here can see: every close above is a user act on
* the document (Escape, the scrim, the trigger, picking a result). An open palette therefore rode
* a popstate onto the next page, aria-modal and Tab-trapped, while the router moved focus behind
* it. Same reasoning as the Appearance popover's — see fs-appearance.js. returnFocus=false because
* the router places focus itself on a navigation. */
* it. returnFocus=false because the router places focus itself on a navigation. */
router.onNavigate(() => close(false));
/* Ctrl/Cmd+K is the shortcut every command palette has taught users, and `/` is the one every
@@ -87,7 +87,7 @@ function enhance(sel) {
if (sel.dataset.fsSelect || sel.disabled) return; /* disabled: NOT marked — it may be enabled later */
/* `multiple` and "not in a CBI field" are permanent, so mark it and stop re-testing on
* every scan */
if (sel.multiple || !sel.closest('.cbi-value-field, .td.cbi-value-field, .cbi-value')) {
if (sel.multiple || !sel.closest('.cbi-value-field, .cbi-value')) {
sel.dataset.fsSelect = 'skip';
return;
}
@@ -100,7 +100,14 @@ function enhance(sel) {
sort: false,
optional: Object.prototype.hasOwnProperty.call(choices, '')
});
} catch (e) { return; }
} catch (e) {
/* Marked, not merely returned from: without the mark the same select is re-selected by
* scan()'s :not([data-fs-select]) on every mutation frame and throws again, forever and
* silently. One loud failure, then left as the stock <select> it already is. */
sel.dataset.fsSelect = 'skip';
console.error('footstrap: a select could not be enhanced', e);
return;
}
const node = dd.render();
const ac = new AbortController();
@@ -210,12 +217,19 @@ function labelCells(t, head) {
for (const row of t.querySelectorAll('.tr, tbody tr')) {
if (row === head) continue;
const cells = row.children;
/* COLUMN cursor, not the cell index: a cell that spans N columns occupies N of the header's
* slots while advancing the cell index by one, so keying titles off `i` captioned every cell
* AFTER a spanning one with the heading of the column to its left — "Hostname" over an IP
* address, and nothing to say the mapping was guessed. Only the spanning cell itself is left
* uncaptioned, because it has no single heading to take. (A rowspan reaching down from an
* earlier row would shift this too; no LuCI table emits one, and a wrong caption is worse
* than none, so that shape stays unhandled rather than approximated.) */
let col = 0;
for (let i = 0; i < cells.length; i++) {
if (i >= titles.length || !titles[i]) continue;
if (cells[i].hasAttribute('data-title')) continue;
/* a cell that spans columns has no single heading to take */
if (cells[i].colSpan > 1) continue;
cells[i].setAttribute('data-title', titles[i]);
const span = (cells[i].colSpan > 1) ? cells[i].colSpan : 1;
if (span === 1 && col < titles.length && titles[col] && !cells[i].hasAttribute('data-title'))
cells[i].setAttribute('data-title', titles[col]);
col += span;
}
}
}
@@ -243,8 +257,11 @@ const STACKABLE = '#view .table.fs-dt';
* unreadable ribbon. Do NOT give the cells a min-width so that "cramped" MANUFACTURES an
* overflow: tried, and it carded the firewall's zone table at 1420px and still overflowed by
* 39px once carded — a floor big enough to force the overflow is big enough to break the card. */
const CRAMPED = 568; /* stock LuCI cards its tables at a 600px viewport; below the 767px
* tier .fs-content pads 16px a side, so 600 -> 568 of room */
const CRAMPED = 568; /* stock LuCI cards its tables at a 600px viewport; below the 767px tier
* .fs-content pads var(--fs-space-4) a side, 16px at the default density,
* so 600 -> 568 of room. A fixed number and not a re-read of that token on
* purpose: the threshold is the DESIGN judgement above, and Compact density
* shrinking the gutter to 10px is not a reason to keep a table wider. */
/* The ribbon has one more shape, and CRAMPED cannot see it: the table has room by the number
* above and still shreds its FIRST column, because auto table layout hands width out by what
@@ -23,7 +23,8 @@
*
* `invasiveSheet()` is that test; its universe is read back from cascade.css itself (same-origin,
* so `cssRules` is readable) rather than a hand-written list, so it tracks the theme. 0.3 ms per
* nav. Exempt: `[data-fs-shell]` (the one <style> the server emits — marked, not guessed at) and
* nav. Exempt: anything the server marked `[data-fs-shell]` — the shell's own two links and two
* styles, marked rather than guessed at (partials/head.ut) — and
* anything inside `#view` (dies with the content swap); LuCI core injects no <style> at runtime at
* all (checked: luci.js, ui.js, cbi.js). If cascade.css cannot be read, EVERY view sheet counts as
* invasive: fail to the slow path, never the broken one. */
@@ -328,7 +329,8 @@ function outlivesPage(el) {
* is real.
*
* WHOLE PATH, NOT A SUFFIX. head.ut prints `{{ resource }}/{{ dispatched.css }}?v=…`, and the base in
* that line is the SAME value the runtime holds: header.ut hands `resource` to `new LuCI({…})`, and
* that line is the SAME value the runtime holds: luci-base's own header.ut hands `resource` to
* `new LuCI({…})`, and
* `L.resource()` joins it back exactly, so the server's href is reconstructable rather than guessable
* — only the cache key has to come off. A suffix match is what a guess costs: anchored at nothing but
* a `/`, `custom.css` matches any sheet ending in that filename, and two in-tree apps append exactly
@@ -339,9 +341,13 @@ function outlivesPage(el) {
* through Adblock → Feeds, and the router would swap into a page whose stylesheet was never linked —
* the one outcome the guard exists to prevent.
*
* Equality also keeps the failure safe: L.path() drops a part that leaves its charset, so a malformed
* `css` yields the bare base and matches no href at all — a full load, which is the correct answer for
* a value nobody can serve. */
* Equality also keeps the failure safe, in the direction that costs only speed. L.path() keeps a part
* only if it matches /^(?:[a-zA-Z0-9_.%,;-]+\/)*[a-zA-Z0-9_.%,;-]+$/ — no `+ ~ ( ) @ ! ' $ &`, nothing
* non-ASCII, no leading `/` — and drops it otherwise, while head.ut interpolates `css` raw and uhttpd
* serves those names without complaint. So a third-party `"css": "view/foo/style(dark).css"` does get
* linked and rendered; `want` collapses to the bare base, matches no href, and every entry into that
* page is a full load. A suffix compare covered those names, and losing them is what closing the false
* positive above costs. No in-tree node sets `css` at all, so the shape is third-party only. */
function documentCarries(path) {
const want = L.resource(String(path));
for (const link of document.querySelectorAll('link[rel~="stylesheet"][href]')) {
@@ -456,8 +462,14 @@ function documentPoisoned() {
const CHROME_FENCE = ':where(:not([data-fs-chrome],[data-fs-chrome] *))';
function fenceSelector(part) {
/* the getter always normalises a pseudo-element to `::`, incl. legacy `:before` */
const i = part.indexOf('::');
/* The getter always normalises a pseudo-element to `::`, incl. legacy `:before` — and the split
* point is found on the MASK and sliced out of the ORIGINAL, exactly as selectorParts() does and
* for the same reason: `::` is legal inside a quoted attribute VALUE (an IPv6 literal is the real
* case — `[data-addr*="::"]`, `a[href*="[::1]"]`). Read raw, the fence went INSIDE the quotes and
* the app's rule came back matching a value it never wrote — still valid CSS, so the setter
* reported success and nothing looked wrong afterwards, which this file's header calls the worse
* half of the two failures. */
const i = maskStrings(part).indexOf('::');
return i < 0 ? part + CHROME_FENCE : part.slice(0, i) + CHROME_FENCE + part.slice(i);
}
@@ -481,8 +493,14 @@ function fenceRules(rules, names) {
/* An @import's rules live in a sheet that is fetched separately, so they are not there the moment
* the shim is inserted — retry until they are, then fence. Bounded: a sheet that never becomes
* readable (404, cross-origin) simply stays unfenced, which is where we already were. */
function fenceImported(styleEl, names, tries) {
* readable (404, cross-origin) simply stays unfenced, which is where we already were.
*
* The bound is a DEADLINE in ms, not a frame count. It was 60 frames described as "~1 s", which is
* only true at 60 Hz: the same 60 frames is 2 s on a 30 Hz panel and longer still on a throttled
* clock, so the budget moved with the display. Frames remain the retry TICK (they are what the
* browser gives for free and a cache hit lands on the first one); only the giving-up point is
* measured in time. */
function fenceImported(styleEl, names, until) {
/* no initialiser: every path below assigns (the try, or the catch's null), so `= null`
* here was a dead store — eslint 10 puts no-useless-assignment in recommended and said so. */
let rules;
@@ -491,7 +509,15 @@ function fenceImported(styleEl, names, tries) {
rules = first && first.styleSheet && first.styleSheet.cssRules;
} catch (e) { rules = null; }
if (rules) { fenceRules(rules, names); return; }
if (tries > 0) requestAnimationFrame(() => fenceImported(styleEl, names, tries - 1));
if (Date.now() < until) { requestAnimationFrame(() => fenceImported(styleEl, names, until)); return; }
/* GIVING UP IS A REPORTABLE EVENT. The irreversible half already happened — the app's original
* is silenced, the shim owns the page — so a fence that never lands leaves that app's rules
* reaching the chrome while every later pass skips the sheet as handled and documentPoisoned()
* calls the document clean. It is also not only the slow-router case: rAF does not fire in a
* background tab, so a page opened in one and left there passes the deadline without a single
* retry. Rare, silent and it looks like a CSS bug, which is exactly what a console line is for. */
console.error('footstrap: could not read the re-hosted @import within the deadline — the sheet '
+ 'stays unfenced and may repaint the chrome on this page.', styleEl);
}
/* What a sheet IS, as text: the rules that are APPLYING, not the markup that may or may not have
@@ -553,11 +579,7 @@ const _silenced = new WeakSet();
*/
function silence(el) {
_silenced.add(el);
el.disabled = true;
if (el.sheet) { el.sheet.disabled = true; return; }
/* no sheet yet: re-assert once there is one. `once` — the element is marked fsLayered, so this
* never re-arms, and a sheet that never loads has nothing to silence. */
el.addEventListener('load', () => { if (el.sheet) el.sheet.disabled = true; }, { once: true });
setEnabled(el, false);
}
/* ---- PAGE OWNERSHIP: contain an invasive sheet instead of spending the document ----
@@ -590,7 +612,7 @@ function silence(el) {
* Disabling is reversible, which is the whole difference.
*
* OWNER = body[data-page] when the sheet was re-hosted. The order that makes this sound is in
* fs-router.js: it stamps data-page (line ~371) BEFORE require()ing the view class (~450), so at the
* fs-router.js: navigate() stamps data-page BEFORE it require()s the view class, so at the
* moment a view module evaluates and appends its <style>, the attribute already names ITS page. On a
* full load the server stamped it. Either way "now" is the sheet's own page.
*
@@ -643,6 +665,28 @@ function appKey(segs) {
* /admin/status renders the card as `inline-block 240px 96px`; System -> General and back leaves
* it `block 966px` — every interface card on the Overview stacked full-width. Keyed on the
* dispatch path: 240px before and after. */
/* WHO A SHEET INJECTED RIGHT NOW BELONGS TO, when that is not the page the chrome is showing.
*
* On a FIRST visit the require() of a view IS its render, and a require in flight cannot be
* stopped: click a page whose module injects CSS, click away before it lands, and the app's <style>
* appears after the router has already stamped data-page for the page that superseded it. Credited
* to currentKey() that sheet is bound to the wrong page for the life of the document — disabled on
* its own page and ENABLED on one it has no business painting, which for luci-app-filemanager means
* `.cbi-button-save { display: none !important }` on somebody else's config form (reproduced on the
* stand: the System page came back with no Save button, and stayed that way across return visits).
*
* So the router names the owner for the duration of such a require (see fs-router.js), and this is
* that hint. It is a plain value rather than a stack: two cold requires cannot overlap, because the
* second navigation supersedes the first before it starts one. */
let _ownerHint = null;
function attributeTo(segs) {
_ownerHint = (segs == null) ? null : appKey(segs);
}
function ownerKey() {
return (_ownerHint !== null) ? _ownerHint : currentKey();
}
function currentKey() {
if (_curKey !== null) return _curKey;
const dp = L.env && L.env.dispatchpath;
@@ -652,9 +696,14 @@ function currentKey() {
return appKey(p ? p.split('/') : []);
}
/* Both halves, for the reason silence() documents: el.disabled is the ELEMENT's flag and
* el.sheet.disabled is what decides whether the CSS paints, and a still-loading <link> has no
* .sheet for the assignment to reach. */
/* BOTH HALVES, for the reason the paragraph above silence() measured: el.disabled is the ELEMENT's
* flag and el.sheet.disabled is what decides whether the CSS paints, and a still-loading <link> has
* no .sheet for the assignment to reach — so a switch-off also re-asserts once the bytes arrive.
* `once` — the element is marked fsLayered by then, so this never re-arms, and a sheet that never
* loads has nothing to silence.
*
* silence() is this with the element remembered, and says so by calling it: the two used to write
* the rule out separately, which is one copy of a JUDGEMENT more than this file allows itself. */
function setEnabled(el, on) {
el.disabled = !on;
if (el.sheet) el.sheet.disabled = !on;
@@ -686,8 +735,8 @@ function rehostIntoThemeLayer(el, universe) {
el.dataset.fsLayered = '1';
el.after(s); /* keep source order: ties inside the layer still resolve as they did */
silence(el);
_owner.set(s, currentKey()); /* the shim paints; the original is silenced for good */
fenceImported(s, universe.names, 60); /* ~1s of frames; a cache hit lands on the first */
_owner.set(s, ownerKey()); /* the shim paints; the original is silenced for good */
fenceImported(s, universe.names, Date.now() + 1000); /* a cache hit lands on the first frame */
return;
}
@@ -703,7 +752,7 @@ function rehostIntoThemeLayer(el, universe) {
* unpinned all over again and a second pass appends a second fence. The mark is the only thing
* that says the work is done, so it has to be set for every path below, wrapped or not. */
el.dataset.fsLayered = '1';
_owner.set(el, currentKey()); /* a <style> is re-hosted IN PLACE, so it paints itself */
_owner.set(el, ownerKey()); /* a <style> is re-hosted IN PLACE, so it paints itself */
/* Wrap only if the text still IS the sheet (see textIsSheet). When it is not, the sheet stays
* unlayered — Zone 2 exactly where it already was, which is a trade — rather than lose rules,
@@ -887,6 +936,7 @@ function watchViewSheets() {
}
return baseclass.extend({
attributeTo,
documentCarries,
documentPoisoned,
scopeToCurrentPage,
@@ -34,6 +34,6 @@ return baseclass.extend({
* API paths from, and nothing has needed the bare `owner/name` since. */
REPO_URL: 'https://github.com/VizzleTF/luci-theme-footstrap',
isReal,
/* what the popover's version row prints */
/* what the Appearance tab's version row prints */
label: () => (isReal() ? ('Footstrap v' + FS_VERSION) : 'Footstrap (dev)')
});
@@ -1,11 +1,15 @@
'use strict';
'require baseclass';
'require fs-prefs as prefs';
/* The theme's own UI primitives: the disclosure pair the menu is built on, the two controls the
* Appearance popover is built on, and the popup placement both hand-placed popups share. Nothing
* here knows what it is being used FOR — that is the point, and it is why the menu and the popover
* can each take what they need without either requiring the other. */
/* The theme's own UI primitives: the one inline-SVG wrapper, the disclosure pair the menu is built
* on, and the colour control the Appearance tab's nine colour axes are built on. Nothing here knows
* what it is being used FOR — that is the point, and it is why the menu and the Appearance tab can
* each take what they need without either requiring the other.
*
* A popup PLACEMENT helper lived here too, shared by the menu's dropdown clamp and the Appearance
* panel when that was a floating popover on <body>. The panel is a tab on System -> System now
* (fs-appearance.js) and places nothing, so the helper had one caller left and went with it; the
* edge gap it clamped against belongs to the menu, which is the only thing still clamping. */
/* The chrome's ONE inline-SVG wrapper. Every icon this theme draws is the same 24x24 stroked
* outline and differs only in its path data, but the wrapper was written out per call site — in the
@@ -112,15 +116,47 @@ function probeColor(expr) {
return getComputedStyle(_probe).color;
}
/* A computed colour string -> [r,g,b] 0..255, or null. Engines do not agree on the notation: both
* `rgb(9, 105, 218)` and the space-separated `rgb(9 105 218 / .5)` are current, and a colour outside
* sRGB (which oklch() readily produces) comes back from some engines as `color(srgb .03 .41 .85)`,
* i.e. 0..1 floats. Take the first three numbers and scale by whether the form is the 0..1 one —
* the alpha after the slash is deliberately ignored, since every token this reads is opaque. */
/* A computed colour -> [r,g,b] 0..255, or null. RASTERISED, never parsed out of the string, and that
* is not a preference: a computed `color` keeps the SPACE it was authored in, so an axis in hue mode
* comes back as `oklch(0.54 0.19 300)`. Read as numbers that is [0.54, 0.19, 300] — three values in
* the wrong units, clamped into a colour nobody chose — and every consumer here believed it: the hex
* field, the `<input type="color">` swatch beside it and the WCAG readout. Measured on the stand with
* `tint=120, accent=300`: a near-white green canvas whose real text contrast is ~16:1 reported #010078 and "Too
* faint to read", and confirming the swatch would have written that navy over the admin's own hue.
*
* Painting one pixel and reading it back asks the engine to do the conversion instead. It is the
* same method tools/export-tier.mjs uses, and for the same reason its header gives: "a color-mix()
* computes to whatever space it was written in, and `oklch(L C H)` has three numbers that parse
* perfectly well — silently and wrongly — as an rgb() triple". The parse survives only as the
* fallback for an engine with no 2D context, where the legacy `rgb()`/`color(srgb …)` forms are all
* that can appear anyway. */
let _cx = null;
function rasterCtx() {
if (_cx !== null) return _cx;
try {
const cv = document.createElement('canvas');
cv.width = cv.height = 1;
_cx = cv.getContext('2d', { willReadFrequently: true }) || false;
} catch (e) { _cx = false; }
return _cx;
}
function parseColor(s) {
const nums = String(s || '').match(/[\d.]+/g);
const str = String(s || '');
const cx = rasterCtx();
if (cx) {
/* fillStyle KEEPS the last value it could parse, so a colour this engine rejects would report
* the previous one as a fresh reading — the same trap probeColor() clears its own style for. */
cx.fillStyle = '#000';
cx.fillStyle = str;
cx.clearRect(0, 0, 1, 1);
cx.fillRect(0, 0, 1, 1);
const d = cx.getImageData(0, 0, 1, 1).data;
if (d[3] === 255) return [ d[0], d[1], d[2] ];
/* translucent: composite over nothing is meaningless for a readout, so fall through */
}
const nums = str.match(/[\d.]+/g);
if (!nums || nums.length < 3) return null;
const unit = (/^color\(/i).test(String(s)) ? 255 : 1;
const unit = (/^color\(/i).test(str) ? 255 : 1;
return nums.slice(0, 3).map((n) => Math.max(0, Math.min(255, parseFloat(n) * unit)));
}
@@ -258,36 +294,6 @@ function colorControl(current, onPick, label, opts) {
return wrap;
}
/* How close a popup may come to the viewport edge before it is nudged back in. Read by BOTH popups
* the theme places by hand — the Appearance popover (fs-appearance.js) and the menu's dropdown
* edge-clamp (menu-footstrap.js) — which had each written their own `8`. */
const EDGE_GAP = 8;
/* Place the popover next to its trigger and keep it inside the viewport. It is position:fixed on
* <body> because the sidebar is `overflow-y: auto` (which computes overflow-x to `auto` too), so
* an absolutely-positioned popover parented to the Appearance row was clipped off the sidebar
* edge. The top bar opens downward from the button's right edge, the sidebar sideways out of the
* rail; both are then clamped. */
function placePopover(btn, pop) {
const gap = EDGE_GAP, r = btn.getBoundingClientRect();
const w = pop.offsetWidth, h = pop.offsetHeight;
const vw = document.documentElement.clientWidth;
const vh = document.documentElement.clientHeight;
const top_layout = prefs.isTopLayout();
let left = top_layout ? (r.right - w) : (r.right + gap);
let top = top_layout ? (r.bottom + gap) : (r.bottom - h);
/* sidebar: if there is no room to the right, fall back above the trigger */
if (!top_layout && left + w > vw - gap) {
left = r.left;
top = r.top - h - gap;
}
pop.style.left = Math.max(gap, Math.min(left, vw - w - gap)) + 'px';
pop.style.top = Math.max(gap, Math.min(top, vh - h - gap)) + 'px';
}
return baseclass.extend({
svgIcon,
setOpen,
@@ -295,7 +301,5 @@ return baseclass.extend({
wireDismiss,
colorControl,
probeColor,
toHex,
EDGE_GAP,
placePopover
toHex
});
@@ -16,7 +16,7 @@
*
* fs-menutree path <-> menu node, alias/firstchild resolution (a port of dispatcher.uc)
* fs-prefs the Appearance axes and their localStorage
* fs-widgets disclosure primitives, the seg/slider controls, popup placement
* fs-widgets the inline-SVG wrapper, the disclosure primitives, the colour control
* fs-chrome mode menu, section tabs, the rail toggle, the "does it still fit" measurements
* fs-router the SPA client router (docs/spa-router.md)
* fs-sheets the guard against a view's injected CSS repainting every later page
@@ -76,7 +76,7 @@ return baseclass.extend({
router.wire();
router.wireVisibility();
/* fs-chrome's renderTabMenu warns about exactly this, and the root chain was left bare: a
* throw anywhere in the calls above took out the menu, the router and the Appearance popover
* throw anywhere in the calls above took out the menu, the router and the Appearance tab
* together, silently. It still fails — there is no sane partial recovery — but loudly. */
}).catch((e) => console.error('footstrap: chrome init failed', e));
}
@@ -9,7 +9,7 @@
/* The theme's ONE menu renderer: a vertical #topmenu the CSS also turns into the top bar and the
* rail flyouts — same markup, no second renderer (docs/chrome.md). The disclosure primitives it builds
* sections on come from fs-widgets and the auto-collapse preference from fs-prefs; the rest of the
* chrome (mode menu, tabs, rail, router, popover) is bootstrapped by menu-footstrap-common, which
* chrome (mode menu, tabs, rail, router, Appearance tab) is bootstrapped by menu-footstrap-common, which
* this file composes with by injecting renderMainMenu into common.init — a callback, not an
* override, because a required LuCI module is a singleton and cannot be subclassed (docs/conventions.md).
* Spec: docs/chrome.md */
@@ -67,9 +67,8 @@ const OPEN_LI = '#topmenu > li.open';
* item near the right edge would push its panel past the viewport. Nudge it back inside. Runs at
* ANY width now that the top bar is measured (no 768 floor); the rail flies panels out sideways
* and needs a different placement, so it is excluded below. */
/* the viewport edge gap, defined once in fs-widgets.js — the Appearance popover keeps a popup off
* the edge by the same amount, and the two used to state it separately */
const EDGE_GAP = widgets.EDGE_GAP;
/* how close the panel may come to the viewport edge before it is nudged back in */
const EDGE_GAP = 8;
/* Is this panel a BAR dropdown (anchored under its item) rather than a rail flyout (anchored
* beside it)? Ask what the STYLESHEET asks, exactly as flyoutMode() does: `data-narrow` is what
* turns the sidebar layout into a bar, and it also disables the rail (the rail's rules are all
+5 -4
View File
@@ -4,9 +4,9 @@
# ./mangle-tokens.sh <cascade.css> <reserved-source-dir>...
#
# Run from the package Makefile over $(PKG_BUILD_DIR) only — NEVER over a dev build. The names are
# 16.1% of the sheet (21640 B over 1770 occurrences, 123 distinct) and mean nothing to a browser;
# 16.6% of the sheet (22586 B over 1824 occurrences, 137 distinct) and mean nothing to a browser;
# this is the same trade terser already makes for the JS, where top-level identifiers are mangled
# because a LuCI resource file is function-scoped. Measured: 134240 -> ~119600, i.e. -14.6 KB, and
# because a LuCI resource file is function-scoped. Measured: 135655 -> 123935, i.e. -11.7 KB, and
# uhttpd serves /www with no compression, so those are wire bytes on every cold load as well as
# flash bytes.
#
@@ -16,12 +16,13 @@
# luci-app reads a `--fs-` name.
# * The RESERVED set is DERIVED, not listed: every `--fs-` name that appears in the theme's JS or
# in a .ut template crosses a seam (fs-prefs.js writes `--fs-tint`, head.ut's pre-paint writes
# `--fs-radius-base`, fs-chrome.js reads `--fs-sidebar-w` …) and keeps its name. 15 of 123 today.
# `--fs-radius-base`, fs-chrome.js reads `--fs-sidebar-w` …) and keeps its name. 35 of 137 today.
# A new one cannot be forgotten, because nothing here names them.
# * Point the dirs at the SOURCE tree, not at $(PKG_BUILD_DIR). In CI the build tree's JS has
# already been through terser and its comments are gone, so five names that only appear in a
# comment stopped being reserved — the same source produced a different sheet depending on who
# built it (10 reserved via CI, 15 via a plain SDK build). Reading the source over-reserves by
# built it (measured at the time: 10 reserved via CI against 15 via a plain SDK build, of a
# smaller reserved set than today's 35). Reading the source over-reserves by
# about a kilobyte, and over-reserving is the direction that cannot break anything.
# * The scan is STRING-AWARE and reads the WHOLE identifier before deciding. A prefix match would
# be a silent corruption: `--fs-space-2` is a prefix of `--fs-space-2-5`.
+132 -112
View File
@@ -11,145 +11,145 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 3.9\n"
#: htdocs/luci-static/resources/fs-appearance.js:232
#: htdocs/luci-static/resources/fs-appearance.js:238
msgctxt "footstrap"
msgid "Accent"
msgstr "Acento"
#: htdocs/luci-static/resources/fs-appearance.js:357
#: htdocs/luci-static/resources/fs-appearance.js:361
msgctxt "footstrap"
msgid "As in file"
msgstr "Como en el archivo"
#: ucode/template/themes/footstrap/sysauth.ut:37
#: ucode/template/themes/footstrap/sysauth.ut:70
msgid "Authorization Required"
msgstr "Se requiere autorización"
#: htdocs/luci-static/resources/fs-appearance.js:169
#: htdocs/luci-static/resources/fs-appearance.js:172
msgctxt "footstrap"
msgid "Auto"
msgstr "Auto"
#: htdocs/luci-static/resources/fs-appearance.js:199
#: htdocs/luci-static/resources/fs-appearance.js:205
msgctxt "footstrap"
msgid "Auto-collapse"
msgstr "Colapso automático"
#: htdocs/luci-static/resources/fs-appearance.js:615
#: htdocs/luci-static/resources/fs-appearance.js:653
msgctxt "footstrap"
msgid "Background"
msgstr "Fondo"
#: htdocs/luci-static/resources/fs-widgets.js:223
#: htdocs/luci-static/resources/fs-widgets.js:259
msgctxt "footstrap"
msgid "Barely visible %s"
msgstr "Apenas visible %s"
#: htdocs/luci-static/resources/fs-appearance.js:288
#: htdocs/luci-static/resources/fs-appearance.js:294
msgctxt "footstrap"
msgid "Borders"
msgstr "Bordes"
#: htdocs/luci-static/resources/fs-appearance.js:269
#: htdocs/luci-static/resources/fs-appearance.js:275
msgctxt "footstrap"
msgid "Cards"
msgstr "Tarjetas"
#: htdocs/luci-static/resources/fs-appearance.js:342
#: htdocs/luci-static/resources/fs-appearance.js:331
msgctxt "footstrap"
msgid "Choose SVG"
msgstr "Elegir SVG"
#: htdocs/luci-static/resources/fs-appearance.js:309
#: htdocs/luci-static/resources/fs-appearance.js:311
#: htdocs/luci-static/resources/fs-appearance.js:324
msgctxt "footstrap"
msgid "Choose image"
msgstr "Elegir imagen"
#: htdocs/luci-static/resources/fs-widgets.js:222
#: htdocs/luci-static/resources/fs-widgets.js:258
msgctxt "footstrap"
msgid "Clearly visible %s"
msgstr "Bien visible %s"
#: htdocs/luci-static/resources/fs-chrome.js:369
#: ucode/template/themes/footstrap/header.ut:133
#: htdocs/luci-static/resources/fs-chrome.js:372
#: ucode/template/themes/footstrap/header.ut:144
msgid "Collapse menu"
msgstr "Contraer menú"
#: htdocs/luci-static/resources/fs-appearance.js:347
#: htdocs/luci-static/resources/fs-appearance.js:612
#: htdocs/luci-static/resources/fs-appearance.js:344
#: htdocs/luci-static/resources/fs-appearance.js:650
msgctxt "footstrap"
msgid "Colours"
msgstr "Colores"
#: htdocs/luci-static/resources/fs-appearance.js:182
#: htdocs/luci-static/resources/fs-appearance.js:188
msgctxt "footstrap"
msgid "Compact"
msgstr "Compacto"
#: htdocs/luci-static/resources/fs-appearance.js:508
#: htdocs/luci-static/resources/fs-appearance.js:545
msgctxt "footstrap"
msgid "Confirm reset"
msgstr "Confirmar restablecimiento"
#: htdocs/luci-static/resources/fs-widgets.js:232
#: htdocs/luci-static/resources/fs-widgets.js:268
msgctxt "footstrap"
msgid "Contrast %s:1 (WCAG AA wants %s:1 here)"
msgstr "Contraste %s:1 (WCAG AA exige aquí %s:1)"
#: htdocs/luci-static/resources/fs-appearance.js:275
#: htdocs/luci-static/resources/fs-appearance.js:281
msgctxt "footstrap"
msgid "Controls"
msgstr "Controles"
#: htdocs/luci-static/resources/fs-prefs.js:1014
#: htdocs/luci-static/resources/fs-prefs.js:1016
#: htdocs/luci-static/resources/fs-prefs.js:1099
#: htdocs/luci-static/resources/fs-prefs.js:1101
msgctxt "footstrap"
msgid "Could not process the image."
msgstr "No se pudo procesar la imagen."
#: htdocs/luci-static/resources/fs-appearance.js:484
#: htdocs/luci-static/resources/fs-appearance.js:521
msgctxt "footstrap"
msgid "Could not save the default. Reload the page and try again."
msgstr ""
"No se pudo guardar la configuración predeterminada. Recargue la página e "
"inténtelo de nuevo."
#: htdocs/luci-static/resources/fs-appearance.js:250
#: htdocs/luci-static/resources/fs-appearance.js:256
msgctxt "footstrap"
msgid "Danger"
msgstr "Peligro"
#: htdocs/luci-static/resources/fs-appearance.js:171
#: htdocs/luci-static/resources/fs-appearance.js:174
msgctxt "footstrap"
msgid "Dark"
msgstr "Oscuro"
#: htdocs/luci-static/resources/fs-appearance.js:616
#: htdocs/luci-static/resources/fs-appearance.js:654
msgctxt "footstrap"
msgid "Defaults"
msgstr "Predeterminados"
#: htdocs/luci-static/resources/fs-appearance.js:181
#: htdocs/luci-static/resources/fs-appearance.js:187
msgctxt "footstrap"
msgid "Density"
msgstr "Densidad"
#: htdocs/luci-static/resources/fs-appearance.js:318
#: htdocs/luci-static/resources/fs-appearance.js:341
msgctxt "footstrap"
msgid "Dim"
msgstr "Oscurecimiento"
#: htdocs/luci-static/resources/fs-widgets.js:225
#: htdocs/luci-static/resources/fs-widgets.js:261
msgctxt "footstrap"
msgid "Easy to read %s"
msgstr "Se lee bien %s"
#: htdocs/luci-static/resources/fs-chrome.js:369
#: htdocs/luci-static/resources/fs-chrome.js:372
msgid "Expand menu"
msgstr "Expandir menú"
#: htdocs/luci-static/resources/fs-appearance.js:413
#: htdocs/luci-static/resources/fs-appearance.js:366
#: htdocs/luci-static/resources/fs-appearance.js:451
msgctxt "footstrap"
msgid "File"
msgstr "Archivo"
@@ -170,22 +170,22 @@ msgstr "Ir a la actualización del firmware..."
msgid "Go to password configuration..."
msgstr "Ir a la configuración de contraseñas..."
#: htdocs/luci-static/resources/fs-appearance.js:238
#: htdocs/luci-static/resources/fs-appearance.js:244
msgctxt "footstrap"
msgid "Good"
msgstr "Correcto"
#: htdocs/luci-static/resources/fs-widgets.js:227
#: htdocs/luci-static/resources/fs-widgets.js:263
msgctxt "footstrap"
msgid "Hard to read %s — large text only"
msgstr "Se lee mal %s: solo texto grande"
#: htdocs/luci-static/resources/fs-appearance.js:611
#: htdocs/luci-static/resources/fs-appearance.js:649
msgctxt "footstrap"
msgid "Interface"
msgstr "Interfaz"
#: ucode/template/themes/footstrap/sysauth.ut:32
#: ucode/template/themes/footstrap/sysauth.ut:60
msgid "Invalid username and/or password! Please try again."
msgstr "¡Nombre de usuario y/o contraseña incorrectos! Inténtelo de nuevo."
@@ -193,49 +193,49 @@ msgstr "¡Nombre de usuario y/o contraseña incorrectos! Inténtelo de nuevo."
msgid "JavaScript required!"
msgstr "¡Se requiere JavaScript!"
#: htdocs/luci-static/resources/fs-appearance.js:198
#: htdocs/luci-static/resources/fs-appearance.js:204
msgctxt "footstrap"
msgid "Keep open"
msgstr "Mantener abierto"
#: htdocs/luci-static/resources/fs-appearance.js:184
#: htdocs/luci-static/resources/fs-appearance.js:190
msgctxt "footstrap"
msgid "Large"
msgstr "Grande"
#: htdocs/luci-static/resources/fs-appearance.js:163
#: htdocs/luci-static/resources/fs-appearance.js:166
msgctxt "footstrap"
msgid "Layout"
msgstr "Diseño"
#: htdocs/luci-static/resources/fs-appearance.js:170
#: htdocs/luci-static/resources/fs-appearance.js:173
msgctxt "footstrap"
msgid "Light"
msgstr "Claro"
#: htdocs/luci-static/resources/fs-router.js:466
#: htdocs/luci-static/resources/fs-router.js:501
msgid "Loading view…"
msgstr "Cargando vista…"
#: ucode/template/themes/footstrap/sysauth.ut:58
#: ucode/template/themes/footstrap/sysauth.ut:142
msgid "Log in"
msgstr "Iniciar sesión"
#: ucode/template/themes/footstrap/partials/logout.ut:11
#: ucode/template/themes/footstrap/partials/logout.ut:13
msgid "Logout"
msgid "Log out"
msgstr "Cerrar sesión"
#: ucode/template/themes/footstrap/partials/footer.ut:27
msgid "Lua compatibility mode active"
msgstr "Modo de compatibilidad Lua activo"
#: htdocs/luci-static/resources/fs-overview.js:34
#: htdocs/luci-static/resources/fs-overview.js:32
msgid "Memory"
msgstr "Memoria"
#: ucode/template/themes/footstrap/header.ut:130
#: ucode/template/themes/footstrap/header.ut:141
#: ucode/template/themes/footstrap/header.ut:152
msgid "Menu"
msgstr "Menú"
@@ -248,7 +248,7 @@ msgstr ""
"reiniciar el sistema. Este modo solo debe usarse para instalar una "
"actualización de firmware"
#: htdocs/luci-static/resources/fs-search.js:316
#: htdocs/luci-static/resources/fs-search.js:320
msgctxt "footstrap"
msgid "No pages found"
msgstr "No se encontraron páginas"
@@ -257,155 +257,157 @@ msgstr "No se encontraron páginas"
msgid "No password set!"
msgstr "¡No se ha establecido ninguna contraseña!"
#: htdocs/luci-static/resources/fs-appearance.js:183
#: htdocs/luci-static/resources/fs-appearance.js:189
msgctxt "footstrap"
msgid "Normal"
msgstr "Normal"
#: htdocs/luci-static/resources/fs-appearance.js:411
#: htdocs/luci-static/resources/fs-appearance.js:449
msgctxt "footstrap"
msgid "Off"
msgstr "Apagado"
#: htdocs/luci-static/resources/fs-search.js:233
#: htdocs/luci-static/resources/fs-search.js:237
msgctxt "footstrap"
msgid "Pages"
msgstr "Páginas"
#: htdocs/luci-static/resources/fs-appearance.js:174
#: htdocs/luci-static/resources/fs-widgets.js:180
#: htdocs/luci-static/resources/fs-appearance.js:177
#: htdocs/luci-static/resources/fs-widgets.js:216
msgctxt "footstrap"
msgid "Palette"
msgstr "Paleta"
#: ucode/template/themes/footstrap/sysauth.ut:49
#: ucode/template/themes/footstrap/sysauth.ut:82
msgid "Password"
msgstr "Contraseña"
#: htdocs/luci-static/resources/fs-appearance.js:412
#: htdocs/luci-static/resources/fs-appearance.js:352
#: htdocs/luci-static/resources/fs-appearance.js:450
msgctxt "footstrap"
msgid "Pattern"
msgstr "Patrón"
#: htdocs/luci-static/resources/fs-prefs.js:882
#: htdocs/luci-static/resources/fs-prefs.js:884
#: htdocs/luci-static/resources/fs-prefs.js:945
#: htdocs/luci-static/resources/fs-prefs.js:947
msgctxt "footstrap"
msgid "Please choose an SVG file."
msgstr "Elija un archivo SVG."
#: htdocs/luci-static/resources/fs-prefs.js:1029
#: htdocs/luci-static/resources/fs-prefs.js:1131
msgctxt "footstrap"
msgid "Please choose an image file."
msgstr "Por favor, seleccione un archivo de imagen."
#: ucode/template/themes/footstrap/sysauth.ut:39
#: ucode/template/themes/footstrap/sysauth.ut:72
msgid "Please enter your username and password."
msgstr "Por favor, ingrese su nombre de usuario y contraseña."
#: htdocs/luci-static/resources/fs-search.js:315
#: htdocs/luci-static/resources/fs-search.js:319
msgctxt "footstrap"
msgid "Recently visited"
msgstr "Visitado recientemente"
#: htdocs/luci-static/resources/fs-appearance.js:310
#: htdocs/luci-static/resources/fs-appearance.js:344
#: htdocs/luci-static/resources/fs-appearance.js:326
#: htdocs/luci-static/resources/fs-appearance.js:333
msgctxt "footstrap"
msgid "Remove"
msgstr "Eliminar"
#: htdocs/luci-static/resources/fs-appearance.js:459
#: htdocs/luci-static/resources/fs-appearance.js:519
#: htdocs/luci-static/resources/fs-appearance.js:483
#: htdocs/luci-static/resources/fs-appearance.js:556
msgctxt "footstrap"
msgid "Reset to default"
msgstr "Restablecer a valores predeterminados"
#: htdocs/luci-static/resources/fs-appearance.js:458
#: htdocs/luci-static/resources/fs-appearance.js:518
#: htdocs/luci-static/resources/fs-appearance.js:478
#: htdocs/luci-static/resources/fs-appearance.js:555
msgctxt "footstrap"
msgid "Reset to saved"
msgstr "Restablecer a lo guardado"
#: htdocs/luci-static/resources/fs-appearance.js:187
#: htdocs/luci-static/resources/fs-appearance.js:193
msgctxt "footstrap"
msgid "Rounding"
msgstr "Redondeo"
#: htdocs/luci-static/resources/fs-appearance.js:532
#: htdocs/luci-static/resources/fs-appearance.js:569
msgctxt "footstrap"
msgid "Router default"
msgstr "Configuración predeterminada del enrutador"
#: htdocs/luci-static/resources/fs-appearance.js:452
#: htdocs/luci-static/resources/fs-appearance.js:473
#: htdocs/luci-static/resources/fs-appearance.js:472
#: htdocs/luci-static/resources/fs-appearance.js:503
#: htdocs/luci-static/resources/fs-appearance.js:510
msgctxt "footstrap"
msgid "Save as default"
msgstr "Guardar como predeterminado"
#: htdocs/luci-static/resources/fs-appearance.js:473
#: htdocs/luci-static/resources/fs-appearance.js:510
msgctxt "footstrap"
msgid "Saved as default"
msgstr "Guardado como predeterminado"
#: htdocs/luci-static/resources/fs-appearance.js:345
#: htdocs/luci-static/resources/fs-appearance.js:342
msgctxt "footstrap"
msgid "Scale"
msgstr "Escala"
#: ucode/template/themes/footstrap/partials/search.ut:14
#: ucode/template/themes/footstrap/partials/search.ut:16
#: ucode/template/themes/footstrap/partials/search.ut:18
msgid "Search"
msgstr "Buscar"
#: htdocs/luci-static/resources/fs-search.js:227
#: htdocs/luci-static/resources/fs-search.js:254
#: htdocs/luci-static/resources/fs-search.js:231
#: htdocs/luci-static/resources/fs-search.js:258
msgctxt "footstrap"
msgid "Search pages"
msgstr "Buscar páginas"
#: htdocs/luci-static/resources/fs-search.js:228
#: htdocs/luci-static/resources/fs-search.js:232
msgctxt "footstrap"
msgid "Search pages…"
msgstr "Buscar páginas…"
#: htdocs/luci-static/resources/fs-appearance.js:164
#: htdocs/luci-static/resources/fs-appearance.js:167
msgctxt "footstrap"
msgid "Sidebar"
msgstr "Barra lateral"
#: htdocs/luci-static/resources/fs-appearance.js:281
#: htdocs/luci-static/resources/fs-appearance.js:287
msgctxt "footstrap"
msgid "Sidebar and bar"
msgstr "Barra lateral y superior"
#: ucode/template/themes/footstrap/header.ut:109
#: ucode/template/themes/footstrap/header.ut:120
msgid "Skip to content"
msgstr "Saltar al contenido"
#: htdocs/luci-static/resources/fs-search.js:316
#: htdocs/luci-static/resources/fs-search.js:320
msgctxt "footstrap"
msgid "Start typing to find a page"
msgstr "Empiece a escribir para encontrar una página"
#: htdocs/luci-static/resources/fs-overview.js:34
#: htdocs/luci-static/resources/fs-overview.js:32
msgid "Storage"
msgstr "Almacenamiento"
#: htdocs/luci-static/resources/fs-appearance.js:346
#: htdocs/luci-static/resources/fs-appearance.js:343
msgctxt "footstrap"
msgid "Strength"
msgstr "Intensidad"
#: htdocs/luci-static/resources/fs-appearance.js:196
#: htdocs/luci-static/resources/fs-appearance.js:202
msgctxt "footstrap"
msgid "Submenus"
msgstr "Submenús"
#: htdocs/luci-static/resources/fs-appearance.js:613
#: htdocs/luci-static/resources/fs-appearance.js:651
msgctxt "footstrap"
msgid "Surfaces"
msgstr "Superficies"
#: htdocs/luci-static/resources/fs-overview.js:34
#: htdocs/luci-static/resources/fs-overview.js:32
msgid "System"
msgstr "Sistema"
@@ -413,7 +415,7 @@ msgstr "Sistema"
msgid "System running in recovery (initramfs) mode."
msgstr "El sistema se está ejecutando en modo de recuperación (initramfs)."
#: htdocs/luci-static/resources/fs-prefs.js:830
#: htdocs/luci-static/resources/fs-prefs.js:893
msgctxt "footstrap"
msgid ""
"That SVG contains script or external references, which this theme will not "
@@ -421,34 +423,39 @@ msgid ""
msgstr ""
"Ese SVG contiene scripts o referencias externas, y este tema no lo instalará."
#: htdocs/luci-static/resources/fs-prefs.js:854
#: htdocs/luci-static/resources/fs-prefs.js:917
msgctxt "footstrap"
msgid "That file could not be read."
msgstr "No se ha podido leer el archivo."
#: htdocs/luci-static/resources/fs-prefs.js:1018
#: htdocs/luci-static/resources/fs-prefs.js:1103
msgctxt "footstrap"
msgid "That file is not a readable image."
msgstr "Ese archivo no es una imagen legible."
#: htdocs/luci-static/resources/fs-prefs.js:826
#: htdocs/luci-static/resources/fs-prefs.js:829
#: htdocs/luci-static/resources/fs-prefs.js:889
#: htdocs/luci-static/resources/fs-prefs.js:892
msgctxt "footstrap"
msgid "That file is not an SVG image."
msgstr "Ese archivo no es una imagen SVG."
#: htdocs/luci-static/resources/fs-prefs.js:885
#: htdocs/luci-static/resources/fs-prefs.js:948
msgctxt "footstrap"
msgid "That file is too large."
msgstr "Ese archivo es demasiado grande."
#: htdocs/luci-static/resources/fs-prefs.js:1031
#: htdocs/luci-static/resources/fs-prefs.js:1133
msgctxt "footstrap"
msgid "That image is too large."
msgstr "Esa imagen es demasiado grande."
#: htdocs/luci-static/resources/fs-appearance.js:168
#: htdocs/luci-static/resources/fs-appearance.js:356
#: htdocs/luci-static/resources/fs-prefs.js:1026
msgctxt "footstrap"
msgid "The router refused to delete the file (ubus status %d)."
msgstr "El router se negó a eliminar el archivo (estado ubus %d)."
#: htdocs/luci-static/resources/fs-appearance.js:171
#: htdocs/luci-static/resources/fs-appearance.js:360
msgctxt "footstrap"
msgid "Theme"
msgstr "Tema"
@@ -461,51 +468,56 @@ msgstr ""
"Este enrutador no tiene contraseña. Configure una contraseña de "
"administrador para proteger la interfaz web."
#: htdocs/luci-static/resources/fs-appearance.js:208
#: htdocs/luci-static/resources/fs-appearance.js:504
msgctxt "footstrap"
msgid "This browser is not storing preferences (site data is blocked), so a change here lasts until you reload. Saving as default still works and applies to every browser."
msgstr "Este navegador no guarda preferencias (los datos del sitio están bloqueados), así que un cambio aquí dura hasta que recargues. Guardar como predeterminado sigue funcionando y se aplica a todos los navegadores."
#: htdocs/luci-static/resources/fs-appearance.js:214
msgctxt "footstrap"
msgid "Tint (router identification)"
msgstr "Tinte (identificación del enrutador)"
#: htdocs/luci-static/resources/fs-appearance.js:223
#: htdocs/luci-static/resources/fs-appearance.js:229
msgctxt "footstrap"
msgid "Tint strength"
msgstr "Intensidad del tinte"
#: htdocs/luci-static/resources/fs-widgets.js:228
#: htdocs/luci-static/resources/fs-widgets.js:264
msgctxt "footstrap"
msgid "Too faint to read %s"
msgstr "Demasiado tenue para leerse %s"
#: htdocs/luci-static/resources/fs-appearance.js:165
#: htdocs/luci-static/resources/fs-appearance.js:168
msgctxt "footstrap"
msgid "Top"
msgstr "Arriba"
#: htdocs/luci-static/resources/fs-prefs.js:897
#: htdocs/luci-static/resources/fs-prefs.js:900
#: htdocs/luci-static/resources/fs-prefs.js:958
#: htdocs/luci-static/resources/fs-prefs.js:1042
#: htdocs/luci-static/resources/fs-prefs.js:1045
#: htdocs/luci-static/resources/fs-prefs.js:960
#: htdocs/luci-static/resources/fs-prefs.js:963
#: htdocs/luci-static/resources/fs-prefs.js:1043
#: htdocs/luci-static/resources/fs-prefs.js:1144
#: htdocs/luci-static/resources/fs-prefs.js:1147
msgctxt "footstrap"
msgid "Upload failed."
msgstr "Error al cargar el archivo."
#: htdocs/luci-static/resources/fs-appearance.js:383
#: htdocs/luci-static/resources/fs-appearance.js:422
#: htdocs/luci-static/resources/fs-appearance.js:402
#: htdocs/luci-static/resources/fs-appearance.js:432
msgctxt "footstrap"
msgid "Uploading…"
msgstr "Cargando…"
#: ucode/template/themes/footstrap/sysauth.ut:43
#: ucode/template/themes/footstrap/sysauth.ut:76
msgid "Username"
msgstr "Nombre de usuario"
#: htdocs/luci-static/resources/fs-appearance.js:303
#: htdocs/luci-static/resources/fs-appearance.js:447
msgctxt "footstrap"
msgid "Wallpaper"
msgstr "Fondo de pantalla"
#: htdocs/luci-static/resources/fs-appearance.js:244
#: htdocs/luci-static/resources/fs-appearance.js:250
msgctxt "footstrap"
msgid "Warning"
msgstr "Advertencia"
@@ -516,31 +528,39 @@ msgid ""
msgstr ""
"Debe activar JavaScript en su navegador o LuCI no funcionará correctamente."
#: htdocs/luci-static/resources/fs-appearance.js:284
#: htdocs/luci-static/resources/fs-appearance.js:290
msgctxt "footstrap"
msgid "in the sidebar"
msgstr "en la barra lateral"
#: htdocs/luci-static/resources/fs-appearance.js:235
#: htdocs/luci-static/resources/fs-appearance.js:241
#: htdocs/luci-static/resources/fs-appearance.js:247
#: htdocs/luci-static/resources/fs-appearance.js:253
#: htdocs/luci-static/resources/fs-appearance.js:272
#: htdocs/luci-static/resources/fs-appearance.js:291
#: htdocs/luci-static/resources/fs-appearance.js:259
#: htdocs/luci-static/resources/fs-appearance.js:278
#: htdocs/luci-static/resources/fs-appearance.js:297
msgctxt "footstrap"
msgid "on a card"
msgstr "en una tarjeta"
#: htdocs/luci-static/resources/fs-appearance.js:278
#: htdocs/luci-static/resources/fs-appearance.js:284
msgctxt "footstrap"
msgid "on a control"
msgstr "en un control"
#: htdocs/luci-static/resources/fs-appearance.js:213
#: htdocs/luci-static/resources/fs-appearance.js:219
msgctxt "footstrap"
msgid "on the canvas"
msgstr "en el lienzo"
#: htdocs/luci-static/resources/fs-prefs.js:1121
msgctxt "footstrap"
msgid "the uploaded file could not be removed either; it is still on the router."
msgstr "tampoco se pudo eliminar el archivo subido; sigue en el router."
#~ msgid "Logout"
#~ msgstr "Cerrar sesión"
#~ msgctxt "footstrap"
#~ msgid "Amber"
#~ msgstr "Ámbar"
+133 -113
View File
@@ -7,143 +7,143 @@ msgstr ""
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
#: htdocs/luci-static/resources/fs-appearance.js:232
#: htdocs/luci-static/resources/fs-appearance.js:238
msgctxt "footstrap"
msgid "Accent"
msgstr "Акцент"
#: htdocs/luci-static/resources/fs-appearance.js:357
#: htdocs/luci-static/resources/fs-appearance.js:361
msgctxt "footstrap"
msgid "As in file"
msgstr "Как в файле"
#: ucode/template/themes/footstrap/sysauth.ut:37
#: ucode/template/themes/footstrap/sysauth.ut:70
msgid "Authorization Required"
msgstr "Требуется авторизация"
#: htdocs/luci-static/resources/fs-appearance.js:169
#: htdocs/luci-static/resources/fs-appearance.js:172
msgctxt "footstrap"
msgid "Auto"
msgstr "Авто"
#: htdocs/luci-static/resources/fs-appearance.js:199
#: htdocs/luci-static/resources/fs-appearance.js:205
msgctxt "footstrap"
msgid "Auto-collapse"
msgstr "Сворачивать"
#: htdocs/luci-static/resources/fs-appearance.js:615
#: htdocs/luci-static/resources/fs-appearance.js:653
msgctxt "footstrap"
msgid "Background"
msgstr "Фон"
#: htdocs/luci-static/resources/fs-widgets.js:223
#: htdocs/luci-static/resources/fs-widgets.js:259
msgctxt "footstrap"
msgid "Barely visible %s"
msgstr "Едва заметна %s"
#: htdocs/luci-static/resources/fs-appearance.js:288
#: htdocs/luci-static/resources/fs-appearance.js:294
msgctxt "footstrap"
msgid "Borders"
msgstr "Границы"
#: htdocs/luci-static/resources/fs-appearance.js:269
#: htdocs/luci-static/resources/fs-appearance.js:275
msgctxt "footstrap"
msgid "Cards"
msgstr "Карточки"
#: htdocs/luci-static/resources/fs-appearance.js:342
#: htdocs/luci-static/resources/fs-appearance.js:331
msgctxt "footstrap"
msgid "Choose SVG"
msgstr "Выбрать SVG"
#: htdocs/luci-static/resources/fs-appearance.js:309
#: htdocs/luci-static/resources/fs-appearance.js:311
#: htdocs/luci-static/resources/fs-appearance.js:324
msgctxt "footstrap"
msgid "Choose image"
msgstr "Выбрать изображение"
#: htdocs/luci-static/resources/fs-widgets.js:222
#: htdocs/luci-static/resources/fs-widgets.js:258
msgctxt "footstrap"
msgid "Clearly visible %s"
msgstr "Хорошо видна %s"
#: htdocs/luci-static/resources/fs-chrome.js:369
#: ucode/template/themes/footstrap/header.ut:133
#: htdocs/luci-static/resources/fs-chrome.js:372
#: ucode/template/themes/footstrap/header.ut:144
msgid "Collapse menu"
msgstr "Свернуть меню"
#: htdocs/luci-static/resources/fs-appearance.js:347
#: htdocs/luci-static/resources/fs-appearance.js:612
#: htdocs/luci-static/resources/fs-appearance.js:344
#: htdocs/luci-static/resources/fs-appearance.js:650
msgctxt "footstrap"
msgid "Colours"
msgstr "Цвета"
#: htdocs/luci-static/resources/fs-appearance.js:182
#: htdocs/luci-static/resources/fs-appearance.js:188
msgctxt "footstrap"
msgid "Compact"
msgstr "Компактно"
#: htdocs/luci-static/resources/fs-appearance.js:508
#: htdocs/luci-static/resources/fs-appearance.js:545
msgctxt "footstrap"
msgid "Confirm reset"
msgstr "Подтвердить сброс"
#: htdocs/luci-static/resources/fs-widgets.js:232
#: htdocs/luci-static/resources/fs-widgets.js:268
msgctxt "footstrap"
msgid "Contrast %s:1 (WCAG AA wants %s:1 here)"
msgstr "Контраст %s:1 (WCAG AA требует здесь %s:1)"
#: htdocs/luci-static/resources/fs-appearance.js:275
#: htdocs/luci-static/resources/fs-appearance.js:281
msgctxt "footstrap"
msgid "Controls"
msgstr "Элементы форм"
#: htdocs/luci-static/resources/fs-prefs.js:1014
#: htdocs/luci-static/resources/fs-prefs.js:1016
#: htdocs/luci-static/resources/fs-prefs.js:1099
#: htdocs/luci-static/resources/fs-prefs.js:1101
msgctxt "footstrap"
msgid "Could not process the image."
msgstr "Не удалось обработать изображение."
#: htdocs/luci-static/resources/fs-appearance.js:484
#: htdocs/luci-static/resources/fs-appearance.js:521
msgctxt "footstrap"
msgid "Could not save the default. Reload the page and try again."
msgstr "Не удалось сохранить умолчание. Обновите страницу и повторите."
#: htdocs/luci-static/resources/fs-appearance.js:250
#: htdocs/luci-static/resources/fs-appearance.js:256
msgctxt "footstrap"
msgid "Danger"
msgstr "Опасность"
#: htdocs/luci-static/resources/fs-appearance.js:171
#: htdocs/luci-static/resources/fs-appearance.js:174
msgctxt "footstrap"
msgid "Dark"
msgstr "Тёмная"
#: htdocs/luci-static/resources/fs-appearance.js:616
#: htdocs/luci-static/resources/fs-appearance.js:654
msgctxt "footstrap"
msgid "Defaults"
msgstr "Умолчания"
#: htdocs/luci-static/resources/fs-appearance.js:181
#: htdocs/luci-static/resources/fs-appearance.js:187
msgctxt "footstrap"
msgid "Density"
msgstr "Плотность"
#: htdocs/luci-static/resources/fs-appearance.js:318
#: htdocs/luci-static/resources/fs-appearance.js:341
msgctxt "footstrap"
msgid "Dim"
msgstr "Затемнение"
#: htdocs/luci-static/resources/fs-widgets.js:225
#: htdocs/luci-static/resources/fs-widgets.js:261
msgctxt "footstrap"
msgid "Easy to read %s"
msgstr "Хорошо читается %s"
#: htdocs/luci-static/resources/fs-chrome.js:369
#: htdocs/luci-static/resources/fs-chrome.js:372
msgid "Expand menu"
msgstr "Развернуть меню"
#: htdocs/luci-static/resources/fs-appearance.js:413
#: htdocs/luci-static/resources/fs-appearance.js:366
#: htdocs/luci-static/resources/fs-appearance.js:451
msgctxt "footstrap"
msgid "File"
msgstr "Файл"
@@ -164,22 +164,22 @@ msgstr "Перейти к обновлению прошивки..."
msgid "Go to password configuration..."
msgstr "Перейти к настройке пароля..."
#: htdocs/luci-static/resources/fs-appearance.js:238
#: htdocs/luci-static/resources/fs-appearance.js:244
msgctxt "footstrap"
msgid "Good"
msgstr "Норма"
#: htdocs/luci-static/resources/fs-widgets.js:227
#: htdocs/luci-static/resources/fs-widgets.js:263
msgctxt "footstrap"
msgid "Hard to read %s — large text only"
msgstr "Плохо читается %s — только крупный текст"
#: htdocs/luci-static/resources/fs-appearance.js:611
#: htdocs/luci-static/resources/fs-appearance.js:649
msgctxt "footstrap"
msgid "Interface"
msgstr "Интерфейс"
#: ucode/template/themes/footstrap/sysauth.ut:32
#: ucode/template/themes/footstrap/sysauth.ut:60
msgid "Invalid username and/or password! Please try again."
msgstr "Неверное имя пользователя и/или пароль! Попробуйте ещё раз."
@@ -187,49 +187,49 @@ msgstr "Неверное имя пользователя и/или пароль!
msgid "JavaScript required!"
msgstr "Требуется JavaScript!"
#: htdocs/luci-static/resources/fs-appearance.js:198
#: htdocs/luci-static/resources/fs-appearance.js:204
msgctxt "footstrap"
msgid "Keep open"
msgstr "Не сворачивать"
#: htdocs/luci-static/resources/fs-appearance.js:184
#: htdocs/luci-static/resources/fs-appearance.js:190
msgctxt "footstrap"
msgid "Large"
msgstr "Крупно"
#: htdocs/luci-static/resources/fs-appearance.js:163
#: htdocs/luci-static/resources/fs-appearance.js:166
msgctxt "footstrap"
msgid "Layout"
msgstr "Раскладка"
#: htdocs/luci-static/resources/fs-appearance.js:170
#: htdocs/luci-static/resources/fs-appearance.js:173
msgctxt "footstrap"
msgid "Light"
msgstr "Светлая"
#: htdocs/luci-static/resources/fs-router.js:466
#: htdocs/luci-static/resources/fs-router.js:501
msgid "Loading view…"
msgstr "Загрузка представления…"
#: ucode/template/themes/footstrap/sysauth.ut:58
#: ucode/template/themes/footstrap/sysauth.ut:142
msgid "Log in"
msgstr "Войти"
#: ucode/template/themes/footstrap/partials/logout.ut:11
#: ucode/template/themes/footstrap/partials/logout.ut:13
msgid "Logout"
msgstr "Выход"
msgid "Log out"
msgstr "Выйти"
#: ucode/template/themes/footstrap/partials/footer.ut:27
msgid "Lua compatibility mode active"
msgstr "Включён режим совместимости с Lua"
#: htdocs/luci-static/resources/fs-overview.js:34
#: htdocs/luci-static/resources/fs-overview.js:32
msgid "Memory"
msgstr "Память"
#: ucode/template/themes/footstrap/header.ut:130
#: ucode/template/themes/footstrap/header.ut:141
#: ucode/template/themes/footstrap/header.ut:152
msgid "Menu"
msgstr "Меню"
@@ -241,7 +241,7 @@ msgstr ""
"Изменения настроек не сохраняются и будут потеряны после перезагрузки. Этот "
"режим следует использовать только для установки обновления прошивки"
#: htdocs/luci-static/resources/fs-search.js:316
#: htdocs/luci-static/resources/fs-search.js:320
msgctxt "footstrap"
msgid "No pages found"
msgstr "Страницы не найдены"
@@ -250,155 +250,157 @@ msgstr "Страницы не найдены"
msgid "No password set!"
msgstr "Пароль не установлен!"
#: htdocs/luci-static/resources/fs-appearance.js:183
#: htdocs/luci-static/resources/fs-appearance.js:189
msgctxt "footstrap"
msgid "Normal"
msgstr "Обычно"
#: htdocs/luci-static/resources/fs-appearance.js:411
#: htdocs/luci-static/resources/fs-appearance.js:449
msgctxt "footstrap"
msgid "Off"
msgstr "Выкл."
#: htdocs/luci-static/resources/fs-search.js:233
#: htdocs/luci-static/resources/fs-search.js:237
msgctxt "footstrap"
msgid "Pages"
msgstr "Страницы"
#: htdocs/luci-static/resources/fs-appearance.js:174
#: htdocs/luci-static/resources/fs-widgets.js:180
#: htdocs/luci-static/resources/fs-appearance.js:177
#: htdocs/luci-static/resources/fs-widgets.js:216
msgctxt "footstrap"
msgid "Palette"
msgstr "Палитра"
#: ucode/template/themes/footstrap/sysauth.ut:49
#: ucode/template/themes/footstrap/sysauth.ut:82
msgid "Password"
msgstr "Пароль"
#: htdocs/luci-static/resources/fs-appearance.js:412
#: htdocs/luci-static/resources/fs-appearance.js:352
#: htdocs/luci-static/resources/fs-appearance.js:450
msgctxt "footstrap"
msgid "Pattern"
msgstr "Узор"
#: htdocs/luci-static/resources/fs-prefs.js:882
#: htdocs/luci-static/resources/fs-prefs.js:884
#: htdocs/luci-static/resources/fs-prefs.js:945
#: htdocs/luci-static/resources/fs-prefs.js:947
msgctxt "footstrap"
msgid "Please choose an SVG file."
msgstr "Выберите файл SVG."
#: htdocs/luci-static/resources/fs-prefs.js:1029
#: htdocs/luci-static/resources/fs-prefs.js:1131
msgctxt "footstrap"
msgid "Please choose an image file."
msgstr "Выберите файл изображения."
#: ucode/template/themes/footstrap/sysauth.ut:39
#: ucode/template/themes/footstrap/sysauth.ut:72
msgid "Please enter your username and password."
msgstr "Введите имя пользователя и пароль."
#: htdocs/luci-static/resources/fs-search.js:315
#: htdocs/luci-static/resources/fs-search.js:319
msgctxt "footstrap"
msgid "Recently visited"
msgstr "Недавние"
#: htdocs/luci-static/resources/fs-appearance.js:310
#: htdocs/luci-static/resources/fs-appearance.js:344
#: htdocs/luci-static/resources/fs-appearance.js:326
#: htdocs/luci-static/resources/fs-appearance.js:333
msgctxt "footstrap"
msgid "Remove"
msgstr "Убрать"
#: htdocs/luci-static/resources/fs-appearance.js:459
#: htdocs/luci-static/resources/fs-appearance.js:519
#: htdocs/luci-static/resources/fs-appearance.js:483
#: htdocs/luci-static/resources/fs-appearance.js:556
msgctxt "footstrap"
msgid "Reset to default"
msgstr "Сбросить к умолчанию"
#: htdocs/luci-static/resources/fs-appearance.js:458
#: htdocs/luci-static/resources/fs-appearance.js:518
#: htdocs/luci-static/resources/fs-appearance.js:478
#: htdocs/luci-static/resources/fs-appearance.js:555
msgctxt "footstrap"
msgid "Reset to saved"
msgstr "Сбросить к сохранённым"
#: htdocs/luci-static/resources/fs-appearance.js:187
#: htdocs/luci-static/resources/fs-appearance.js:193
msgctxt "footstrap"
msgid "Rounding"
msgstr "Скругление"
#: htdocs/luci-static/resources/fs-appearance.js:532
#: htdocs/luci-static/resources/fs-appearance.js:569
msgctxt "footstrap"
msgid "Router default"
msgstr "По умолчанию для роутера"
#: htdocs/luci-static/resources/fs-appearance.js:452
#: htdocs/luci-static/resources/fs-appearance.js:473
#: htdocs/luci-static/resources/fs-appearance.js:472
#: htdocs/luci-static/resources/fs-appearance.js:503
#: htdocs/luci-static/resources/fs-appearance.js:510
msgctxt "footstrap"
msgid "Save as default"
msgstr "Сохранить по умолчанию"
#: htdocs/luci-static/resources/fs-appearance.js:473
#: htdocs/luci-static/resources/fs-appearance.js:510
msgctxt "footstrap"
msgid "Saved as default"
msgstr "Сохранено по умолчанию"
#: htdocs/luci-static/resources/fs-appearance.js:345
#: htdocs/luci-static/resources/fs-appearance.js:342
msgctxt "footstrap"
msgid "Scale"
msgstr "Масштаб"
#: ucode/template/themes/footstrap/partials/search.ut:14
#: ucode/template/themes/footstrap/partials/search.ut:16
#: ucode/template/themes/footstrap/partials/search.ut:18
msgid "Search"
msgstr "Поиск"
#: htdocs/luci-static/resources/fs-search.js:227
#: htdocs/luci-static/resources/fs-search.js:254
#: htdocs/luci-static/resources/fs-search.js:231
#: htdocs/luci-static/resources/fs-search.js:258
msgctxt "footstrap"
msgid "Search pages"
msgstr "Поиск по страницам"
#: htdocs/luci-static/resources/fs-search.js:228
#: htdocs/luci-static/resources/fs-search.js:232
msgctxt "footstrap"
msgid "Search pages…"
msgstr "Поиск по страницам…"
#: htdocs/luci-static/resources/fs-appearance.js:164
#: htdocs/luci-static/resources/fs-appearance.js:167
msgctxt "footstrap"
msgid "Sidebar"
msgstr "Сбоку"
#: htdocs/luci-static/resources/fs-appearance.js:281
#: htdocs/luci-static/resources/fs-appearance.js:287
msgctxt "footstrap"
msgid "Sidebar and bar"
msgstr "Боковая панель и шапка"
#: ucode/template/themes/footstrap/header.ut:109
#: ucode/template/themes/footstrap/header.ut:120
msgid "Skip to content"
msgstr "Перейти к содержимому"
#: htdocs/luci-static/resources/fs-search.js:316
#: htdocs/luci-static/resources/fs-search.js:320
msgctxt "footstrap"
msgid "Start typing to find a page"
msgstr "Начните вводить название страницы"
#: htdocs/luci-static/resources/fs-overview.js:34
#: htdocs/luci-static/resources/fs-overview.js:32
msgid "Storage"
msgstr "Накопитель"
#: htdocs/luci-static/resources/fs-appearance.js:346
#: htdocs/luci-static/resources/fs-appearance.js:343
msgctxt "footstrap"
msgid "Strength"
msgstr "Насыщенность"
#: htdocs/luci-static/resources/fs-appearance.js:196
#: htdocs/luci-static/resources/fs-appearance.js:202
msgctxt "footstrap"
msgid "Submenus"
msgstr "Подменю"
#: htdocs/luci-static/resources/fs-appearance.js:613
#: htdocs/luci-static/resources/fs-appearance.js:651
msgctxt "footstrap"
msgid "Surfaces"
msgstr "Поверхности"
#: htdocs/luci-static/resources/fs-overview.js:34
#: htdocs/luci-static/resources/fs-overview.js:32
msgid "System"
msgstr "Система"
@@ -406,7 +408,7 @@ msgstr "Система"
msgid "System running in recovery (initramfs) mode."
msgstr "Система работает в режиме восстановления (initramfs)."
#: htdocs/luci-static/resources/fs-prefs.js:830
#: htdocs/luci-static/resources/fs-prefs.js:893
msgctxt "footstrap"
msgid ""
"That SVG contains script or external references, which this theme will not "
@@ -414,34 +416,39 @@ msgid ""
msgstr ""
"В этом SVG есть скрипт или внешние ссылки — тема не станет его устанавливать."
#: htdocs/luci-static/resources/fs-prefs.js:854
#: htdocs/luci-static/resources/fs-prefs.js:917
msgctxt "footstrap"
msgid "That file could not be read."
msgstr "Не удалось прочитать файл."
#: htdocs/luci-static/resources/fs-prefs.js:1018
#: htdocs/luci-static/resources/fs-prefs.js:1103
msgctxt "footstrap"
msgid "That file is not a readable image."
msgstr "Не удалось прочитать файл как изображение."
#: htdocs/luci-static/resources/fs-prefs.js:826
#: htdocs/luci-static/resources/fs-prefs.js:829
#: htdocs/luci-static/resources/fs-prefs.js:889
#: htdocs/luci-static/resources/fs-prefs.js:892
msgctxt "footstrap"
msgid "That file is not an SVG image."
msgstr "Этот файл — не изображение SVG."
#: htdocs/luci-static/resources/fs-prefs.js:885
#: htdocs/luci-static/resources/fs-prefs.js:948
msgctxt "footstrap"
msgid "That file is too large."
msgstr "Файл слишком большой."
#: htdocs/luci-static/resources/fs-prefs.js:1031
#: htdocs/luci-static/resources/fs-prefs.js:1133
msgctxt "footstrap"
msgid "That image is too large."
msgstr "Изображение слишком большое."
#: htdocs/luci-static/resources/fs-appearance.js:168
#: htdocs/luci-static/resources/fs-appearance.js:356
#: htdocs/luci-static/resources/fs-prefs.js:1026
msgctxt "footstrap"
msgid "The router refused to delete the file (ubus status %d)."
msgstr "Роутер отказался удалить файл (статус ubus %d)."
#: htdocs/luci-static/resources/fs-appearance.js:171
#: htdocs/luci-static/resources/fs-appearance.js:360
msgctxt "footstrap"
msgid "Theme"
msgstr "Тема"
@@ -454,51 +461,56 @@ msgstr ""
"На этом роутере не задан пароль. Установите пароль root, чтобы защитить веб-"
"интерфейс."
#: htdocs/luci-static/resources/fs-appearance.js:208
#: htdocs/luci-static/resources/fs-appearance.js:504
msgctxt "footstrap"
msgid "This browser is not storing preferences (site data is blocked), so a change here lasts until you reload. Saving as default still works and applies to every browser."
msgstr "Этот браузер не хранит настройки (данные сайта заблокированы), поэтому изменение здесь действует до перезагрузки страницы. «Сохранить как умолчание» по-прежнему работает и применяется ко всем браузерам."
#: htdocs/luci-static/resources/fs-appearance.js:214
msgctxt "footstrap"
msgid "Tint (router identification)"
msgstr "Оттенок (опознание роутера)"
#: htdocs/luci-static/resources/fs-appearance.js:223
#: htdocs/luci-static/resources/fs-appearance.js:229
msgctxt "footstrap"
msgid "Tint strength"
msgstr "Насыщенность оттенка"
#: htdocs/luci-static/resources/fs-widgets.js:228
#: htdocs/luci-static/resources/fs-widgets.js:264
msgctxt "footstrap"
msgid "Too faint to read %s"
msgstr "Слишком блёкло, не читается %s"
#: htdocs/luci-static/resources/fs-appearance.js:165
#: htdocs/luci-static/resources/fs-appearance.js:168
msgctxt "footstrap"
msgid "Top"
msgstr "Сверху"
#: htdocs/luci-static/resources/fs-prefs.js:897
#: htdocs/luci-static/resources/fs-prefs.js:900
#: htdocs/luci-static/resources/fs-prefs.js:958
#: htdocs/luci-static/resources/fs-prefs.js:1042
#: htdocs/luci-static/resources/fs-prefs.js:1045
#: htdocs/luci-static/resources/fs-prefs.js:960
#: htdocs/luci-static/resources/fs-prefs.js:963
#: htdocs/luci-static/resources/fs-prefs.js:1043
#: htdocs/luci-static/resources/fs-prefs.js:1144
#: htdocs/luci-static/resources/fs-prefs.js:1147
msgctxt "footstrap"
msgid "Upload failed."
msgstr "Не удалось загрузить."
#: htdocs/luci-static/resources/fs-appearance.js:383
#: htdocs/luci-static/resources/fs-appearance.js:422
#: htdocs/luci-static/resources/fs-appearance.js:402
#: htdocs/luci-static/resources/fs-appearance.js:432
msgctxt "footstrap"
msgid "Uploading…"
msgstr "Загрузка…"
#: ucode/template/themes/footstrap/sysauth.ut:43
#: ucode/template/themes/footstrap/sysauth.ut:76
msgid "Username"
msgstr "Имя пользователя"
#: htdocs/luci-static/resources/fs-appearance.js:303
#: htdocs/luci-static/resources/fs-appearance.js:447
msgctxt "footstrap"
msgid "Wallpaper"
msgstr "Обои"
#: htdocs/luci-static/resources/fs-appearance.js:244
#: htdocs/luci-static/resources/fs-appearance.js:250
msgctxt "footstrap"
msgid "Warning"
msgstr "Предупреждение"
@@ -509,31 +521,39 @@ msgid ""
msgstr ""
"Включите JavaScript в браузере, иначе LuCI не будет работать корректно."
#: htdocs/luci-static/resources/fs-appearance.js:284
#: htdocs/luci-static/resources/fs-appearance.js:290
msgctxt "footstrap"
msgid "in the sidebar"
msgstr "в боковой панели"
#: htdocs/luci-static/resources/fs-appearance.js:235
#: htdocs/luci-static/resources/fs-appearance.js:241
#: htdocs/luci-static/resources/fs-appearance.js:247
#: htdocs/luci-static/resources/fs-appearance.js:253
#: htdocs/luci-static/resources/fs-appearance.js:272
#: htdocs/luci-static/resources/fs-appearance.js:291
#: htdocs/luci-static/resources/fs-appearance.js:259
#: htdocs/luci-static/resources/fs-appearance.js:278
#: htdocs/luci-static/resources/fs-appearance.js:297
msgctxt "footstrap"
msgid "on a card"
msgstr "на карточке"
#: htdocs/luci-static/resources/fs-appearance.js:278
#: htdocs/luci-static/resources/fs-appearance.js:284
msgctxt "footstrap"
msgid "on a control"
msgstr "на элементе формы"
#: htdocs/luci-static/resources/fs-appearance.js:213
#: htdocs/luci-static/resources/fs-appearance.js:219
msgctxt "footstrap"
msgid "on the canvas"
msgstr "на холсте"
#: htdocs/luci-static/resources/fs-prefs.js:1121
msgctxt "footstrap"
msgid "the uploaded file could not be removed either; it is still on the router."
msgstr "загруженный файл тоже не удалось удалить — он остался на роутере."
#~ msgid "Logout"
#~ msgstr "Выход"
#~ msgctxt "footstrap"
#~ msgid "Amber"
#~ msgstr "Янтарь"
+133 -112
View File
@@ -1,143 +1,143 @@
msgid ""
msgstr "Content-Type: text/plain; charset=UTF-8"
#: htdocs/luci-static/resources/fs-appearance.js:232
#: htdocs/luci-static/resources/fs-appearance.js:238
msgctxt "footstrap"
msgid "Accent"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:357
#: htdocs/luci-static/resources/fs-appearance.js:361
msgctxt "footstrap"
msgid "As in file"
msgstr ""
#: ucode/template/themes/footstrap/sysauth.ut:37
#: ucode/template/themes/footstrap/sysauth.ut:70
msgid "Authorization Required"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:169
#: htdocs/luci-static/resources/fs-appearance.js:172
msgctxt "footstrap"
msgid "Auto"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:199
#: htdocs/luci-static/resources/fs-appearance.js:205
msgctxt "footstrap"
msgid "Auto-collapse"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:615
#: htdocs/luci-static/resources/fs-appearance.js:653
msgctxt "footstrap"
msgid "Background"
msgstr ""
#: htdocs/luci-static/resources/fs-widgets.js:223
#: htdocs/luci-static/resources/fs-widgets.js:259
msgctxt "footstrap"
msgid "Barely visible %s"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:288
#: htdocs/luci-static/resources/fs-appearance.js:294
msgctxt "footstrap"
msgid "Borders"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:269
#: htdocs/luci-static/resources/fs-appearance.js:275
msgctxt "footstrap"
msgid "Cards"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:342
#: htdocs/luci-static/resources/fs-appearance.js:331
msgctxt "footstrap"
msgid "Choose SVG"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:309
#: htdocs/luci-static/resources/fs-appearance.js:311
#: htdocs/luci-static/resources/fs-appearance.js:324
msgctxt "footstrap"
msgid "Choose image"
msgstr ""
#: htdocs/luci-static/resources/fs-widgets.js:222
#: htdocs/luci-static/resources/fs-widgets.js:258
msgctxt "footstrap"
msgid "Clearly visible %s"
msgstr ""
#: htdocs/luci-static/resources/fs-chrome.js:369
#: ucode/template/themes/footstrap/header.ut:133
#: htdocs/luci-static/resources/fs-chrome.js:372
#: ucode/template/themes/footstrap/header.ut:144
msgid "Collapse menu"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:347
#: htdocs/luci-static/resources/fs-appearance.js:612
#: htdocs/luci-static/resources/fs-appearance.js:344
#: htdocs/luci-static/resources/fs-appearance.js:650
msgctxt "footstrap"
msgid "Colours"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:182
#: htdocs/luci-static/resources/fs-appearance.js:188
msgctxt "footstrap"
msgid "Compact"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:508
#: htdocs/luci-static/resources/fs-appearance.js:545
msgctxt "footstrap"
msgid "Confirm reset"
msgstr ""
#: htdocs/luci-static/resources/fs-widgets.js:232
#: htdocs/luci-static/resources/fs-widgets.js:268
msgctxt "footstrap"
msgid "Contrast %s:1 (WCAG AA wants %s:1 here)"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:275
#: htdocs/luci-static/resources/fs-appearance.js:281
msgctxt "footstrap"
msgid "Controls"
msgstr ""
#: htdocs/luci-static/resources/fs-prefs.js:1014
#: htdocs/luci-static/resources/fs-prefs.js:1016
#: htdocs/luci-static/resources/fs-prefs.js:1099
#: htdocs/luci-static/resources/fs-prefs.js:1101
msgctxt "footstrap"
msgid "Could not process the image."
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:484
#: htdocs/luci-static/resources/fs-appearance.js:521
msgctxt "footstrap"
msgid "Could not save the default. Reload the page and try again."
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:250
#: htdocs/luci-static/resources/fs-appearance.js:256
msgctxt "footstrap"
msgid "Danger"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:171
#: htdocs/luci-static/resources/fs-appearance.js:174
msgctxt "footstrap"
msgid "Dark"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:616
#: htdocs/luci-static/resources/fs-appearance.js:654
msgctxt "footstrap"
msgid "Defaults"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:181
#: htdocs/luci-static/resources/fs-appearance.js:187
msgctxt "footstrap"
msgid "Density"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:318
#: htdocs/luci-static/resources/fs-appearance.js:341
msgctxt "footstrap"
msgid "Dim"
msgstr ""
#: htdocs/luci-static/resources/fs-widgets.js:225
#: htdocs/luci-static/resources/fs-widgets.js:261
msgctxt "footstrap"
msgid "Easy to read %s"
msgstr ""
#: htdocs/luci-static/resources/fs-chrome.js:369
#: htdocs/luci-static/resources/fs-chrome.js:372
msgid "Expand menu"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:413
#: htdocs/luci-static/resources/fs-appearance.js:366
#: htdocs/luci-static/resources/fs-appearance.js:451
msgctxt "footstrap"
msgid "File"
msgstr ""
@@ -156,22 +156,22 @@ msgstr ""
msgid "Go to password configuration..."
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:238
#: htdocs/luci-static/resources/fs-appearance.js:244
msgctxt "footstrap"
msgid "Good"
msgstr ""
#: htdocs/luci-static/resources/fs-widgets.js:227
#: htdocs/luci-static/resources/fs-widgets.js:263
msgctxt "footstrap"
msgid "Hard to read %s — large text only"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:611
#: htdocs/luci-static/resources/fs-appearance.js:649
msgctxt "footstrap"
msgid "Interface"
msgstr ""
#: ucode/template/themes/footstrap/sysauth.ut:32
#: ucode/template/themes/footstrap/sysauth.ut:60
msgid "Invalid username and/or password! Please try again."
msgstr ""
@@ -179,49 +179,49 @@ msgstr ""
msgid "JavaScript required!"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:198
#: htdocs/luci-static/resources/fs-appearance.js:204
msgctxt "footstrap"
msgid "Keep open"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:184
#: htdocs/luci-static/resources/fs-appearance.js:190
msgctxt "footstrap"
msgid "Large"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:163
#: htdocs/luci-static/resources/fs-appearance.js:166
msgctxt "footstrap"
msgid "Layout"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:170
#: htdocs/luci-static/resources/fs-appearance.js:173
msgctxt "footstrap"
msgid "Light"
msgstr ""
#: htdocs/luci-static/resources/fs-router.js:466
#: htdocs/luci-static/resources/fs-router.js:501
msgid "Loading view…"
msgstr ""
#: ucode/template/themes/footstrap/sysauth.ut:58
#: ucode/template/themes/footstrap/sysauth.ut:142
msgid "Log in"
msgstr ""
#: ucode/template/themes/footstrap/partials/logout.ut:11
#: ucode/template/themes/footstrap/partials/logout.ut:13
msgid "Logout"
msgid "Log out"
msgstr ""
#: ucode/template/themes/footstrap/partials/footer.ut:27
msgid "Lua compatibility mode active"
msgstr ""
#: htdocs/luci-static/resources/fs-overview.js:34
#: htdocs/luci-static/resources/fs-overview.js:32
msgid "Memory"
msgstr ""
#: ucode/template/themes/footstrap/header.ut:130
#: ucode/template/themes/footstrap/header.ut:141
#: ucode/template/themes/footstrap/header.ut:152
msgid "Menu"
msgstr ""
@@ -231,7 +231,7 @@ msgid ""
"mode should only be used to install a firmware upgrade"
msgstr ""
#: htdocs/luci-static/resources/fs-search.js:316
#: htdocs/luci-static/resources/fs-search.js:320
msgctxt "footstrap"
msgid "No pages found"
msgstr ""
@@ -240,155 +240,157 @@ msgstr ""
msgid "No password set!"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:183
#: htdocs/luci-static/resources/fs-appearance.js:189
msgctxt "footstrap"
msgid "Normal"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:411
#: htdocs/luci-static/resources/fs-appearance.js:449
msgctxt "footstrap"
msgid "Off"
msgstr ""
#: htdocs/luci-static/resources/fs-search.js:233
#: htdocs/luci-static/resources/fs-search.js:237
msgctxt "footstrap"
msgid "Pages"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:174
#: htdocs/luci-static/resources/fs-widgets.js:180
#: htdocs/luci-static/resources/fs-appearance.js:177
#: htdocs/luci-static/resources/fs-widgets.js:216
msgctxt "footstrap"
msgid "Palette"
msgstr ""
#: ucode/template/themes/footstrap/sysauth.ut:49
#: ucode/template/themes/footstrap/sysauth.ut:82
msgid "Password"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:412
#: htdocs/luci-static/resources/fs-appearance.js:352
#: htdocs/luci-static/resources/fs-appearance.js:450
msgctxt "footstrap"
msgid "Pattern"
msgstr ""
#: htdocs/luci-static/resources/fs-prefs.js:882
#: htdocs/luci-static/resources/fs-prefs.js:884
#: htdocs/luci-static/resources/fs-prefs.js:945
#: htdocs/luci-static/resources/fs-prefs.js:947
msgctxt "footstrap"
msgid "Please choose an SVG file."
msgstr ""
#: htdocs/luci-static/resources/fs-prefs.js:1029
#: htdocs/luci-static/resources/fs-prefs.js:1131
msgctxt "footstrap"
msgid "Please choose an image file."
msgstr ""
#: ucode/template/themes/footstrap/sysauth.ut:39
#: ucode/template/themes/footstrap/sysauth.ut:72
msgid "Please enter your username and password."
msgstr ""
#: htdocs/luci-static/resources/fs-search.js:315
#: htdocs/luci-static/resources/fs-search.js:319
msgctxt "footstrap"
msgid "Recently visited"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:310
#: htdocs/luci-static/resources/fs-appearance.js:344
#: htdocs/luci-static/resources/fs-appearance.js:326
#: htdocs/luci-static/resources/fs-appearance.js:333
msgctxt "footstrap"
msgid "Remove"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:459
#: htdocs/luci-static/resources/fs-appearance.js:519
#: htdocs/luci-static/resources/fs-appearance.js:483
#: htdocs/luci-static/resources/fs-appearance.js:556
msgctxt "footstrap"
msgid "Reset to default"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:458
#: htdocs/luci-static/resources/fs-appearance.js:518
#: htdocs/luci-static/resources/fs-appearance.js:478
#: htdocs/luci-static/resources/fs-appearance.js:555
msgctxt "footstrap"
msgid "Reset to saved"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:187
#: htdocs/luci-static/resources/fs-appearance.js:193
msgctxt "footstrap"
msgid "Rounding"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:532
#: htdocs/luci-static/resources/fs-appearance.js:569
msgctxt "footstrap"
msgid "Router default"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:452
#: htdocs/luci-static/resources/fs-appearance.js:473
#: htdocs/luci-static/resources/fs-appearance.js:472
#: htdocs/luci-static/resources/fs-appearance.js:503
#: htdocs/luci-static/resources/fs-appearance.js:510
msgctxt "footstrap"
msgid "Save as default"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:473
#: htdocs/luci-static/resources/fs-appearance.js:510
msgctxt "footstrap"
msgid "Saved as default"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:345
#: htdocs/luci-static/resources/fs-appearance.js:342
msgctxt "footstrap"
msgid "Scale"
msgstr ""
#: ucode/template/themes/footstrap/partials/search.ut:14
#: ucode/template/themes/footstrap/partials/search.ut:16
#: ucode/template/themes/footstrap/partials/search.ut:18
msgid "Search"
msgstr ""
#: htdocs/luci-static/resources/fs-search.js:227
#: htdocs/luci-static/resources/fs-search.js:254
#: htdocs/luci-static/resources/fs-search.js:231
#: htdocs/luci-static/resources/fs-search.js:258
msgctxt "footstrap"
msgid "Search pages"
msgstr ""
#: htdocs/luci-static/resources/fs-search.js:228
#: htdocs/luci-static/resources/fs-search.js:232
msgctxt "footstrap"
msgid "Search pages…"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:164
#: htdocs/luci-static/resources/fs-appearance.js:167
msgctxt "footstrap"
msgid "Sidebar"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:281
#: htdocs/luci-static/resources/fs-appearance.js:287
msgctxt "footstrap"
msgid "Sidebar and bar"
msgstr ""
#: ucode/template/themes/footstrap/header.ut:109
#: ucode/template/themes/footstrap/header.ut:120
msgid "Skip to content"
msgstr ""
#: htdocs/luci-static/resources/fs-search.js:316
#: htdocs/luci-static/resources/fs-search.js:320
msgctxt "footstrap"
msgid "Start typing to find a page"
msgstr ""
#: htdocs/luci-static/resources/fs-overview.js:34
#: htdocs/luci-static/resources/fs-overview.js:32
msgid "Storage"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:346
#: htdocs/luci-static/resources/fs-appearance.js:343
msgctxt "footstrap"
msgid "Strength"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:196
#: htdocs/luci-static/resources/fs-appearance.js:202
msgctxt "footstrap"
msgid "Submenus"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:613
#: htdocs/luci-static/resources/fs-appearance.js:651
msgctxt "footstrap"
msgid "Surfaces"
msgstr ""
#: htdocs/luci-static/resources/fs-overview.js:34
#: htdocs/luci-static/resources/fs-overview.js:32
msgid "System"
msgstr ""
@@ -396,41 +398,46 @@ msgstr ""
msgid "System running in recovery (initramfs) mode."
msgstr ""
#: htdocs/luci-static/resources/fs-prefs.js:830
#: htdocs/luci-static/resources/fs-prefs.js:893
msgctxt "footstrap"
msgid ""
"That SVG contains script or external references, which this theme will not "
"install."
msgstr ""
#: htdocs/luci-static/resources/fs-prefs.js:854
#: htdocs/luci-static/resources/fs-prefs.js:917
msgctxt "footstrap"
msgid "That file could not be read."
msgstr ""
#: htdocs/luci-static/resources/fs-prefs.js:1018
#: htdocs/luci-static/resources/fs-prefs.js:1103
msgctxt "footstrap"
msgid "That file is not a readable image."
msgstr ""
#: htdocs/luci-static/resources/fs-prefs.js:826
#: htdocs/luci-static/resources/fs-prefs.js:829
#: htdocs/luci-static/resources/fs-prefs.js:889
#: htdocs/luci-static/resources/fs-prefs.js:892
msgctxt "footstrap"
msgid "That file is not an SVG image."
msgstr ""
#: htdocs/luci-static/resources/fs-prefs.js:885
#: htdocs/luci-static/resources/fs-prefs.js:948
msgctxt "footstrap"
msgid "That file is too large."
msgstr ""
#: htdocs/luci-static/resources/fs-prefs.js:1031
#: htdocs/luci-static/resources/fs-prefs.js:1133
msgctxt "footstrap"
msgid "That image is too large."
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:168
#: htdocs/luci-static/resources/fs-appearance.js:356
#: htdocs/luci-static/resources/fs-prefs.js:1026
msgctxt "footstrap"
msgid "The router refused to delete the file (ubus status %d)."
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:171
#: htdocs/luci-static/resources/fs-appearance.js:360
msgctxt "footstrap"
msgid "Theme"
msgstr ""
@@ -441,51 +448,59 @@ msgid ""
"protect the web interface."
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:208
#: htdocs/luci-static/resources/fs-appearance.js:504
msgctxt "footstrap"
msgid ""
"This browser is not storing preferences (site data is blocked), so a change "
"here lasts until you reload. Saving as default still works and applies to "
"every browser."
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:214
msgctxt "footstrap"
msgid "Tint (router identification)"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:223
#: htdocs/luci-static/resources/fs-appearance.js:229
msgctxt "footstrap"
msgid "Tint strength"
msgstr ""
#: htdocs/luci-static/resources/fs-widgets.js:228
#: htdocs/luci-static/resources/fs-widgets.js:264
msgctxt "footstrap"
msgid "Too faint to read %s"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:165
#: htdocs/luci-static/resources/fs-appearance.js:168
msgctxt "footstrap"
msgid "Top"
msgstr ""
#: htdocs/luci-static/resources/fs-prefs.js:897
#: htdocs/luci-static/resources/fs-prefs.js:900
#: htdocs/luci-static/resources/fs-prefs.js:958
#: htdocs/luci-static/resources/fs-prefs.js:1042
#: htdocs/luci-static/resources/fs-prefs.js:1045
#: htdocs/luci-static/resources/fs-prefs.js:960
#: htdocs/luci-static/resources/fs-prefs.js:963
#: htdocs/luci-static/resources/fs-prefs.js:1043
#: htdocs/luci-static/resources/fs-prefs.js:1144
#: htdocs/luci-static/resources/fs-prefs.js:1147
msgctxt "footstrap"
msgid "Upload failed."
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:383
#: htdocs/luci-static/resources/fs-appearance.js:422
#: htdocs/luci-static/resources/fs-appearance.js:402
#: htdocs/luci-static/resources/fs-appearance.js:432
msgctxt "footstrap"
msgid "Uploading…"
msgstr ""
#: ucode/template/themes/footstrap/sysauth.ut:43
#: ucode/template/themes/footstrap/sysauth.ut:76
msgid "Username"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:303
#: htdocs/luci-static/resources/fs-appearance.js:447
msgctxt "footstrap"
msgid "Wallpaper"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:244
#: htdocs/luci-static/resources/fs-appearance.js:250
msgctxt "footstrap"
msgid "Warning"
msgstr ""
@@ -495,27 +510,33 @@ msgid ""
"You must enable JavaScript in your browser or LuCI will not work properly."
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:284
#: htdocs/luci-static/resources/fs-appearance.js:290
msgctxt "footstrap"
msgid "in the sidebar"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:235
#: htdocs/luci-static/resources/fs-appearance.js:241
#: htdocs/luci-static/resources/fs-appearance.js:247
#: htdocs/luci-static/resources/fs-appearance.js:253
#: htdocs/luci-static/resources/fs-appearance.js:272
#: htdocs/luci-static/resources/fs-appearance.js:291
#: htdocs/luci-static/resources/fs-appearance.js:259
#: htdocs/luci-static/resources/fs-appearance.js:278
#: htdocs/luci-static/resources/fs-appearance.js:297
msgctxt "footstrap"
msgid "on a card"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:278
#: htdocs/luci-static/resources/fs-appearance.js:284
msgctxt "footstrap"
msgid "on a control"
msgstr ""
#: htdocs/luci-static/resources/fs-appearance.js:213
#: htdocs/luci-static/resources/fs-appearance.js:219
msgctxt "footstrap"
msgid "on the canvas"
msgstr ""
#: htdocs/luci-static/resources/fs-prefs.js:1121
msgctxt "footstrap"
msgid ""
"the uploaded file could not be removed either; it is still on the router."
msgstr ""
@@ -6,16 +6,20 @@
# upgrade branch: on a real router the guard was DEAD, and an upgrade that found an empty
# mediaurlbase would activate this theme behind the user's back. The marker file is the state that
# really tells the two apart — it exists iff this script has completed at least once.
# `${PKG_UPGRADE:-}`, not `$PKG_UPGRADE`: the variable is unset on every real path (that is the
# point of the paragraph above), and this file is `sh`-run by the Makefile today but sourced by
# whatever runs uci-defaults tomorrow — under `set -u` a bare expansion aborts the script here,
# the theme is never registered, and the Makefile's `>/dev/null 2>&1` swallows the reason.
FS_MARKER=/usr/share/luci-theme-footstrap/.installed
if [ "$PKG_UPGRADE" = 1 ] || [ -f "$FS_MARKER" ]; then
if [ "${PKG_UPGRADE:-}" = 1 ] || [ -f "$FS_MARKER" ]; then
PKG_UPGRADE=1
else
PKG_UPGRADE=0
fi
# Every theme name this package has EVER registered — delete them all; the ONE surviving entry
# is re-added below (layout, mode and palette are client-side toggles in the Appearance popover,
# so they are not theme entries). The Makefile's postrm must delete the same set (it runs when
# is re-added below (layout, mode and palette are client-side axes on the Footstrap tab of
# System -> System, so they are not theme entries). The Makefile's postrm must delete the same set (it runs when
# this file is already gone, so it cannot source it) and the two lists had already drifted into a
# different ORDER — which is how one of them would quietly lose a name. Only the assignment is
# pinned: the Makefile writes `$$t` where this writes `$t`.
@@ -26,8 +30,19 @@ for t in $LEGACY_THEMES; do
uci -q delete "luci.themes.$t"
done
# WHETHER THE REGISTRATION ACTUALLY LANDED. Every uci WRITE below is checked into this flag, and a
# run that ends with it set refuses to write the marker and exits non-zero — which is what makes the
# failure survivable: OpenWrt's default_postinst deletes a uci-defaults script only when it exits 0
# (`( . "$i" ) && rm -f "$i"`), so a non-zero run is retried on the next boot. Without this the
# script reported success on a full or read-only overlay, on a locked /etc/config/luci and mid-
# upgrade: the theme was never registered, the script was deleted so nothing retried it, AND the
# marker was written anyway — so the next install took the upgrade branch, which never activates a
# theme. One unwritable file turned into a permanently unthemed router, silently. Deletes are NOT
# counted: `uci -q delete` on an absent entry exits 1 and that is the normal case here.
fail=0
# The ONE theme entry: sidebar and top bar are one template morphed by :root[data-layout].
uci set luci.themes.Footstrap=/luci-static/footstrap
uci set luci.themes.Footstrap=/luci-static/footstrap || fail=1
# Normalise the active theme. Every legacy media path below is now neither registered in
# luci.themes nor on disk, so an install left pointing at one is stranded: the Design page does
@@ -45,7 +60,7 @@ case "$cur" in
# was on the top-nav theme (incl. its legacy dark/light variants) -> keep the bar
/luci-static/footstrap-top*)
[ -n "$(uci -q get luci.main.footstrap_layout)" ] || \
uci set luci.main.footstrap_layout=top
uci set luci.main.footstrap_layout=top || fail=1
new=/luci-static/footstrap ;;
# any other footstrap path (incl. the legacy -dark/-light) -> the built-in default (the bar):
# this branch deliberately sets no footstrap_layout, and absent means top (head.ut)
@@ -73,9 +88,9 @@ case "$new" in
;;
esac
[ "$new" = "$cur" ] || uci set luci.main.mediaurlbase="$new"
[ "$new" = "$cur" ] || uci set luci.main.mediaurlbase="$new" || fail=1
uci commit luci
uci commit luci || fail=1
# Appearance "Save as default" writes the router-wide axis defaults into /etc/config/footstrap
# through the scoped uci ACL (config 'footstrap' only). The rpcd uci `set` backend the browser
@@ -84,8 +99,8 @@ uci commit luci
# to CREATE the config file (only the section within one), so touch it first. Idempotent.
[ -f /etc/config/footstrap ] || touch /etc/config/footstrap
if ! uci -q get footstrap.settings >/dev/null 2>&1; then
uci set footstrap.settings=footstrap
uci commit footstrap
uci set footstrap.settings=footstrap || fail=1
uci commit footstrap || fail=1
fi
# Login/page background upload (Appearance -> Wallpaper -> File). cgi-upload writes the image to
@@ -111,6 +126,13 @@ mkdir -p /etc/footstrap 2>/dev/null
# woff2 files. `-n` does not help — measured on a 25.12 router: exit 0, link created inside.
# So clear the path first unless it is already the symlink we want. What is being deleted can only
# be ours, and its contents are package files of a version that no longer exists.
#
# DO NOT DELETE THIS AS "an old migration". It is needed for every router whose last footstrap was
# v0.12.0 or older, because that is where the package still SHIPPED files in that directory — and
# there is no floor on how old an installed footstrap can be. Without it `ln -sfn` exits 0 with the
# link planted one level down, the admin's webfonts stop serving, and footstrap.settings.fonts
# points at a 404 with nothing reporting anything. The doodle cleanup below is the opposite case:
# dropping it costs 200 kB of flash and nothing else.
if [ -d /www/luci-static/footstrap ]; then
[ ! -e /www/luci-static/footstrap/fonts ] || [ -L /www/luci-static/footstrap/fonts ] ||
rm -rf /www/luci-static/footstrap/fonts
@@ -128,6 +150,12 @@ rm -rf /tmp/luci-modulecache 2>/dev/null
# Write the marker LAST, so a run that dies half-way is still a fresh install next
# time rather than an upgrade.
if [ "$fail" = 1 ]; then
echo "luci-theme-footstrap: a uci write failed — not marking this install as complete;" >&2
echo " this script stays in /etc/uci-defaults and runs again on the next boot." >&2
exit 1
fi
mkdir -p "$(dirname "$FS_MARKER")" 2>/dev/null
: > "$FS_MARKER" 2>/dev/null
+38 -20
View File
@@ -4,8 +4,8 @@
# ./strip-shell.sh <dir>
#
# Same trade as strip-templates.sh and build-css.sh: the reader gets the comments from git, the
# router gets the bytes. `root/etc/uci-defaults/30_luci-theme-footstrap` is 71% comment lines and
# `root/lib/upgrade/keep.d/luci-theme-footstrap` is 95% (six lines of reasoning above one path).
# router gets the bytes. `root/etc/uci-defaults/30_luci-theme-footstrap` is 51% comment lines and
# `root/lib/upgrade/keep.d/luci-theme-footstrap` is 77% (ten lines of reasoning above three paths).
#
# ONLY a line whose first non-blank character is `#`, and never the shebang. That rule is safe here
# and the reason is checked, not assumed: sh has no block-comment syntax, so the only way a `#` can
@@ -21,37 +21,55 @@ set -e
DIR="${1:-}"
[ -n "$DIR" ] && [ -d "$DIR" ] || { echo "usage: strip-shell.sh <dir>" >&2; exit 1; }
if find "$DIR" -type f -exec grep -l '<<' {} + 2>/dev/null | grep -q .; then
echo "strip-shell: a heredoc appeared under $DIR — refusing (a '#' line inside one is DATA, not a comment)" >&2
exit 1
fi
# ONE trap, covering the file list and whatever temp the loop is holding when it dies — the same
# shape build-css.sh, mangle-tokens.sh and update-po.sh already use. Under `set -e` an awk failure
# used to leave `<file>.tmp<pid>` behind, and stage.sh runs this over the staged payload, so the
# leftover would be copied into the package: a `30_luci-theme-footstrap.tmp1234` under
# /etc/uci-defaults is a file the ROUTER executes and deletes on its next boot.
LIST=$(mktemp)
CUR=""
trap 'rm -f "$LIST" ${CUR:+"$CUR"}' EXIT INT TERM
# The list goes through a file rather than `for f in $(find …)`: that splits on whitespace, so a
# checkout under a path with a space in it fed this loop two halves of a directory name and the
# script reported "no shell file" while pointing at the tree instead of at the path.
find "$DIR" -type f | sort > "$LIST"
before=0
after=0
found=0
for f in $(find "$DIR" -type f | sort); do
# shell only: a shebang, or the two comment-only manifests this package ships
while IFS= read -r f; do
# shell only: a shebang, or the one comment-only manifest this package ships (keep.d). The other
# two files under root/ — the uci config and the rpcd ACL — are deliberately left alone.
case "$(head -c 2 "$f")" in
'#!') ;;
*) case "$f" in */keep.d/*) ;; *) continue ;; esac ;;
esac
found=$((found + 1))
b=$(wc -c < "$f")
awk 'NR == 1 && /^#!/ { print; next }
/^[ \t]*#/ { next }
{ print }' "$f" > "$f.tmp$$"
# never leave a file that lost its shebang or came out empty
if [ ! -s "$f.tmp$$" ]; then
echo "strip-shell: $f came out empty — refusing" >&2
rm -f "$f.tmp$$"
# The heredoc refusal, over the files this loop actually REWRITES. It used to run over the whole
# tree, so a `<<` inside the rpcd ACL or the uci config — neither of which is ever touched here —
# aborted the package build for a hazard that cannot apply to them.
if grep -q '<<' "$f"; then
echo "strip-shell: a heredoc in $f — refusing (a '#' line inside one is DATA, not a comment)" >&2
exit 1
fi
cat "$f.tmp$$" > "$f" # cat, not mv: keep the original mode (uci-defaults must stay +x)
rm -f "$f.tmp$$"
found=$((found + 1))
b=$(wc -c < "$f")
CUR="$f.tmp$$"
awk 'NR == 1 && /^#!/ { print; next }
/^[ \t]*#/ { next }
{ print }' "$f" > "$CUR"
# never leave a file that lost its shebang or came out empty
if [ ! -s "$CUR" ]; then
echo "strip-shell: $f came out empty — refusing" >&2
exit 1
fi
cat "$CUR" > "$f" # cat, not mv: keep the original mode (uci-defaults must stay +x)
rm -f "$CUR"
CUR=""
a=$(wc -c < "$f")
before=$((before + b))
after=$((after + a))
done
done < "$LIST"
[ "$found" -gt 0 ] || { echo "strip-shell: no shell file under $DIR" >&2; exit 1; }
echo "strip-shell: $found file(s), $before -> $after bytes (-$((before - after)))"
+20 -8
View File
@@ -6,7 +6,7 @@
# Run from the package Makefile (Build/Prepare) over $(PKG_BUILD_DIR), never over the source tree:
# git keeps every word, the router does not need any of them. Same trade this project already makes
# for JS (jsmin/terser) and CSS (build-css.sh) — templates were simply never included in it, and
# they are 58% comments. `{# … #}` alone is 16112 of 39426 bytes (41%).
# they are mostly comments: `{# … #}` is 22633 of 60227 bytes (38%).
#
# sh + awk only, like build-css.sh: an OpenWrt buildbot has no node and this must not become the
# reason a build needs one.
@@ -23,21 +23,32 @@
# strings. Not worth 6.8 KB.
#
# Whitespace control is EMULATED, not ignored: `{#- …` also eats the whitespace before the comment
# and `… -#}` the whitespace after it, which is how ucode itself renders them. Every .ut here opens
# with a licence block closing `-#}` to swallow the newline before <!DOCTYPE html>; dropping the
# comment without the trim would put that newline back.
# and `… -#}` the whitespace after it, which is how ucode itself renders them. Nearly every .ut here
# opens with a licence block closing `-#}` (footer.ut is one `{% include %}` line and has none);
# head.ut's is the one that matters, swallowing the newline before its <!DOCTYPE html>, and dropping
# the comment without the trim would put that newline back.
set -e
DIR="${1:-}"
[ -n "$DIR" ] && [ -d "$DIR" ] || { echo "usage: strip-templates.sh <dir>" >&2; exit 1; }
# ONE trap over the file list and whatever temp the loop holds, and the list goes through a file
# rather than `for f in $(find …)`: the same two fixes as strip-shell.sh, for the same reasons — a
# checkout path with a space in it was split into words, and an awk failure under `set -e` left a
# `<file>.tmp<pid>` inside the staged payload, which stage.sh then copies into the package.
LIST=$(mktemp)
CUR=""
trap 'rm -f "$LIST" ${CUR:+"$CUR"}' EXIT INT TERM
find "$DIR" -name '*.ut' -type f | sort > "$LIST"
before=0
after=0
found=0
for f in $(find "$DIR" -name '*.ut' -type f | sort); do
while IFS= read -r f; do
found=$((found + 1))
b=$(wc -c < "$f")
CUR="$f.tmp$$"
awk '
BEGIN { RS = "^$" } # slurp the whole file
{
@@ -60,12 +71,13 @@ for f in $(find "$DIR" -name '*.ut' -type f | sort); do
}
printf "%s", out
}
' "$f" > "$f.tmp$$"
mv "$f.tmp$$" "$f"
' "$f" > "$CUR"
mv "$CUR" "$f"
CUR=""
a=$(wc -c < "$f")
before=$((before + b))
after=$((after + a))
done
done < "$LIST"
[ "$found" -gt 0 ] || { echo "strip-templates: no .ut under $DIR" >&2; exit 1; }
echo "strip-templates: $found file(s), $before -> $after bytes (-$((before - after)))"
+76 -33
View File
@@ -42,8 +42,8 @@
* measures 1.15 / 1.27 / 1.17 / 1.29 : 1 across palette x mode. That is a GLOW, not an
* indicator: WCAG 1.4.11 wants 3:1 for the thing that tells you where focus is. It is
* legitimate only BESIDE a second channel — every field here also flips border-color to
* --fs-accent, and that change measures 4.88-6.52:1, so the border is the indicator and
* the tint is the halo around it.
* --fs-accent, and that change measures 4.57-7.04:1 across the three palettes x both modes
* x the three surfaces, so the border is the indicator and the tint is the halo around it.
*
* -solo is for a control where the ring is the WHOLE indicator: nothing else about it
* changes on focus. Three groups were relying on the tint alone and therefore showed
@@ -51,7 +51,8 @@
* buttons (10-chrome.css), the section tabs (40-tabs.css) and both range sliders. The
* 2px of surface is what lets it read against a FILLED control too (an accent-filled
* button or the active tab pill would otherwise take an accent ring on an accent box);
* solid accent then measures 5.19 / 5.39 / 6.52 / 8.52 on panel, 4.88-9.14 on the page.
* solid accent then measures 5.19 / 5.39 / 5.22 / 7.04 / 5.81 / 5.24 on panel across the six
* palette x mode blocks, and 4.61-7.55 on the canvas.
* It had been written out as a literal twice (the filled buttons, the active pill) before
* it had a name — the drift shape the ladder exists to prevent.
*
@@ -127,9 +128,9 @@
--fs-blur: blur(12px);
/* the circular-arrows refresh glyph, as a mask so its colour follows the palette
* (--fs-good active / --fs-dim paused). ONE copy: the poll-status pill collapses to it
* both in the rail (20-shell.css) and in the top bar before it stacks (50-toplayout.css),
* and inlining the SVG twice is exactly the drift css-dup exists to catch. STATIC glyph
* (--fs-good active / --fs-dim paused). ONE copy: the poll-status pill collapses to it both
* in the collapsed rail and in the bar's compact cluster (20-shell.css owns both rules), and
* inlining the SVG twice is exactly the drift css-dup exists to catch. STATIC glyph
* on purpose — see the rail note; a spinner makes an idle poll look busy and moves the
* click target that pauses it. */
/* OURS. It was derived from Lucide's `refresh-cw` (ISC) — `M21 3v5h-5` was byte-identical,
@@ -158,9 +159,11 @@
* `filter` recolours the TEXT too, and a light-mode solid button is a saturated fill
* with WHITE ink, which cannot get brighter — on --fs-accent (#0969da) white is
* 5.19:1 at rest, brightness(1.08) drops it to 4.59:1 and a visible 1.15 to 4.08:1,
* an AA failure caused by hovering. So light DARKENS (0.90 -> 6.16:1: bigger cue AND
* better contrast); dark, whose fill is light and ink dark, brightens (1.15 -> ~8:1).
* Both move the button away from the page it sits on. */
* an AA failure caused by hovering. So light DARKENS: .90 moves fill and ink together
* and lands at 4.94:1 — a hair under the resting 5.19 rather than the 6.16 you get by
* darkening the fill alone, and still clear of the 4.5 floor that brightening breaks.
* Dark, whose fill is light and ink dark, brightens instead (1.15). Both move the button
* away from the page it sits on, which is what the cue has to do. */
--fs-hover-lift: brightness(.90);
/* ---- MOTION — four durations, because the UI does four things. There used to be
@@ -215,9 +218,10 @@
* localStorage `fs-radius`, pre-painted inline on :root by partials/head.ut before
* first paint); the three semantic radii scale from it proportionally so every
* surface rounds in step, and pills stay fully round. At base 12: card 12, control
* 10, chip 8. Every border-radius in theme/pages reads one of these — the only bare
* px left are 0 / 50% / hairline insets and two that are not a card's corner
* (theme/60-inputs.css 8px = half the 16px scrollbar; theme/70-modal.css 4px). */
* 10, chip 8. Every border-radius in theme/pages reads one of these — the only bare px left
* are 0, 50%, and three that are not a card's corner at all: the checkbox tick box's 2px
* (theme/60-inputs.css) and the 1px on each of the two chevron glyphs (pages/20-overview.css,
* pages/80-appearance.css), which are 7px squares rotated 45°. */
--fs-radius-base: 12px;
--fs-radius-lg: var(--fs-radius-base); /* cards, panels, modals, table frame, popovers */
--fs-radius: calc(var(--fs-radius-base) * 5 / 6); /* controls: inputs, buttons, dropdowns, tabs (10 @12) */
@@ -267,7 +271,7 @@
* between a heading and the text under it, where before there was one pixel.
* Ratio is roughly 1.25 per step. Anything that needs a size not on this list is making a
* design decision and should say so in a comment. */
/* ---- DENSITY: one axis, two multipliers, and it only works because of the scales ----
/* ---- DENSITY: one axis, three multipliers, and it only works because of the scales ----
*
* Compact / Normal / Large is a CLIENT preference (Appearance), stamped as
* :root[data-density] — absent means Normal, so the default costs no attribute and no
@@ -276,16 +280,20 @@
* THREE multipliers, because three different kinds of size want different treatment and one
* knob gets all three wrong:
*
* --fs-density-type TEXT. Compact does not touch it at all: "compact" is a request for
* less air, not for smaller writing, and shrinking the text is how a
* density control turns into an accessibility problem.
* --fs-density-type TEXT, and it moves LEAST: .9 at Compact, 1.15 at Large. "Compact" is
* a request for less air, not for smaller writing, so the type gives up
* a tenth where the air gives up a third — at .9 the base is 12px and
* the eyebrow 10px, which is the floor this file is willing to go to,
* and only because the user asked for the mode by name.
* --fs-density-space pure AIR — padding, margin, gap. This is what actually buys the room,
* so it moves the most (.65 at Compact).
* so it moves the most (.65 at Compact). It does NOT grow at Large: the
* point there is bigger text, and inflating the air with it just reads
* as zoom, which is the browser's job.
* --fs-density-box boxes that must HOLD text or an icon: the sidebar column, the rail,
* the bar height, the square icon buttons, the control-height floors.
* These cannot follow the air: with type unchanged, a sidebar cut by
* 35% would no longer fit the menu labels it exists to show. They move
* gently (.85), which is roughly what their own padding gave up.
* These cannot follow the air: the type only gives up a tenth, so a
* sidebar cut by 35% would no longer fit the menu labels it exists to
* show. They move gently (.85 / 1.15).
*
* This is only expressible because every size in the theme now reads one of these tokens:
* before that, a density axis would have meant hunting ~240 literals and would have gone
@@ -312,11 +320,21 @@
--fs-ctl-h: calc(38px * var(--fs-density-box)); /* the default control */
--fs-ctl-h-lg: calc(44px * var(--fs-density-box)); /* prominent: .btn.large, the login submit */
--fs-type-xs: calc(round(11px * var(--fs-density-type), 1px)); /* eyebrow, badges, tooltips, dense metadata */
--fs-type: calc(round(13px * var(--fs-density-type), 1px)); /* body, tables, fields, menu items — the base */
--fs-type-lg: calc(round(16px * var(--fs-density-type), 1px)); /* section titles (h3/legend), alert headings */
--fs-type-xl: calc(round(20px * var(--fs-density-type), 1px)); /* page titles */
--fs-type-2xl: calc(round(26px * var(--fs-density-type), 1px)); /* h1 — only a third-party app emits a bare one */
/* UNROUNDED HERE, ROUNDED IN THE @supports BELOW, and the split is not style: it is the
* difference between a soft failure and no page. `round()` is the YOUNGEST thing this theme
* depends on (Chromium 125, Firefox 118, Safari 15.4 — years after :has(), color-mix() and
* @layer), and a custom property holds any token stream, so an engine without it PARSES these
* fine and fails at SUBSTITUTION: `font-size: var(--fs-type)` is then invalid at
* computed-value time and computes to `unset`. Measured by serving a sheet with `round(`
* renamed: 5881 of 5912 elements on Overview change, body type goes 13px -> 16px, 5880
* font-size and 5733 line-height declarations are lost, and --fs-control-h takes every
* field's min-height with it because it reads --fs-type. Declared this way such an engine
* keeps the whole ladder and pays a fractional pixel at the two non-default densities. */
--fs-type-xs: calc(11px * var(--fs-density-type)); /* eyebrow, badges, tooltips, dense metadata */
--fs-type: calc(13px * var(--fs-density-type)); /* body, tables, fields, menu items — the base */
--fs-type-lg: calc(16px * var(--fs-density-type)); /* section titles (h3/legend), alert headings */
--fs-type-xl: calc(20px * var(--fs-density-type)); /* page titles */
--fs-type-2xl: calc(26px * var(--fs-density-type)); /* h1 — only a third-party app emits a bare one */
/* ---- WEIGHT: there are only ever three, and they had six spellings ----------
* The theme ships no font at all — the stacks name Manrope and JetBrains Mono first and
* fall through to the system faces — so these are not a ramp to pick from: they are the
@@ -383,8 +401,9 @@
--fs-space-6: calc(24px * var(--fs-density-space));
--fs-space-7: calc(28px * var(--fs-density-space));
/* The top of the ladder is sparse on purpose — these three are gutters and insets, not
* rhythm, and each has exactly one or two users (the login card's bottom margin, the gauge's
* right gutter, the room a field leaves for its own chevron). They are named so that no
* rhythm, and each has two or three users: the sidebar accordion's indent and the search
* palette's bottom padding (-7), the room a field leaves for its own chevron (-8), the login
* card's bottom margin and the gauge's two right gutters (-10). They are named so that no
* padding in this theme is an unexplained number, not because a fourth caller is expected. */
--fs-space-8: calc(32px * var(--fs-density-space));
--fs-space-10: calc(40px * var(--fs-density-space));
@@ -400,12 +419,12 @@
/* One control height for every field that is a single line — input, select, .cbi-select,
* .cbi-dropdown, and the checkbox/radio row that must line up beside them. DERIVED, not
* measured by hand: the text box (--fs-text x --fs-leading = 19.5px) plus the vertical
* measured by hand: the text box (--fs-type x --fs-leading = 19.5px) plus the vertical
* inset those fields carry (2 x --fs-space-1) plus both 1px borders = 29.5 -> ceil 32.
* It was a bare 30px in four rules, which was upstream's same arithmetic over upstream's
* 18px leading — so re-scaling the type left every control the wrong height for its own
* text, silently. Stated once here so the two cannot drift again. */
--fs-control-h: calc(round(up, calc(var(--fs-type) * var(--fs-leading) + var(--fs-space-1) * 2 + 2px), 4px));
--fs-control-h: calc(var(--fs-type) * var(--fs-leading) + var(--fs-space-1) * 2 + 2px);
/* HOW WIDE AN ELASTIC CONTROL GETS IN A FORM ROW. A `.cbi-value-field` is `flex: 1`, so
* anything asking for 100% takes the whole card — and a form whose every control ends at a
@@ -536,15 +555,39 @@
color-scheme: light;
}
/* ---- the rounded half of the type ladder, for engines that can round -------------
*
* A fractional font size renders soft, so every size here is meant to land on a whole pixel —
* but `round()` in calc() is younger than everything else this theme needs, and a token that
* uses it on an engine that does not is not a fallback, it is a DELETED token that takes every
* rule reading it (the note by the ladder above has the measurement). So the ladder is declared
* plainly up there and rounded here, where the at-rule can be asked first. --fs-control-h is
* derived from --fs-type and rounds UP to the 4px control ladder for the same reason.
*
* `@supports (width: round(1px, 1px))` and not a `calc()` probe: the function has to be tested
* where a length is expected, and `width` is the plainest property that takes one. */
@supports (width: round(1px, 1px)) {
:root {
--fs-type-xs: calc(round(11px * var(--fs-density-type), 1px));
--fs-type: calc(round(13px * var(--fs-density-type), 1px));
--fs-type-lg: calc(round(16px * var(--fs-density-type), 1px));
--fs-type-xl: calc(round(20px * var(--fs-density-type), 1px));
--fs-type-2xl: calc(round(26px * var(--fs-density-type), 1px));
--fs-control-h: calc(round(up, calc(var(--fs-type) * var(--fs-leading) + var(--fs-space-1) * 2 + 2px), 4px));
}
}
/* ---- the DENSITY axis, and it is exactly two declarations per step ----------------
*
* Normal is a bare :root — no attribute, no override — so the default path costs nothing and a
* browser that has never touched the control renders identically to before this existed.
*
* Space moves further than type in both directions (0.8/1.25 against 0.92/1.15), because that is
* what the two settings are actually for: Compact is a request for less AIR, not for smaller
* writing — matching them would put body text at 10.4px, under the floor the rest of this file
* defends. At Compact the base is 12px and at Large 15px; the eyebrow never drops below 10px.
* Compact moves the air furthest (space .65 against type .9), because that is what the setting is
* actually for: less AIR, not smaller writing — matching the type to the air would put the body
* at 8.5px, far under the floor the rest of this file defends. Large is the mirror image and not
* the symmetric one: type and box grow to 1.15 while the air stays at 1, so the mode reads as
* bigger text rather than as zoom. At Compact the base is 12px and at Large 15px; the eyebrow
* never drops below 10px.
*
* These multiply the LADDERS, not the rules, so a size that is on a ladder follows the axis for
* free and one that is not silently ignores it. That is the whole reason the ladders came first. */
+44 -21
View File
@@ -27,7 +27,7 @@
* THE INKS ARE PER PALETTE AND PER MODE, and that is not a style choice. A single global
* `--fs-on-accent: #fff` used to sit on every accent/good/warn/danger fill (solid buttons,
* the active tab pill, the toggle knob, .fs-seg, the login submit). White holds up on the
* DARK fills of a LIGHT palette and nowhere else: on footstrap dark it measured 2.83–3.70:1
* DARK fills of a LIGHT palette and nowhere else: on footstrap dark it measured 2.77–2.80:1
* and on hicontrast dark --fs-good 1.69:1, where WCAG AA wants 4.5:1 for text and 3:1 for a
* UI shape like the toggle knob. A dark palette has LIGHT fills, so its ink must be DARK.
* Check a new colourway's inks against all four of its own fills: every pair below clears
@@ -74,8 +74,17 @@
* tint was invisible on blue hues and obvious on green ones, i.e. the cue's strength
* depended on WHICH colour you picked. So instead: give the canvas the user's hue at a
* chroma we choose, leave its LIGHTNESS alone (one relative-colour declaration).
* ONE HONEST KNOB --fs-tint-c is the strength, in chroma, and behaves the same on
* every hue — no gamut clipping at these values, no hue surprise.
* ONE HONEST KNOB --fs-tint-c is the strength, in chroma, and in DARK mode it behaves the
* same on every hue: no gamut clipping at these values, no hue surprise.
* Light mode has less headroom and does clip at the top of the wheel
* (measured: footstrap light hue 0 wants 1.011/0.955/0.970 of sRGB and is
* clamped to 255,244,247), which costs a few units of the cue's strength but
* not its direction — every hue still moves the canvas its own way. The one
* surface where that stops being true is a canvas of pure white: on the
* bootstrap colourway's #ffffff, 180° and 258° land 1/255 apart, so the cue
* is nearly hue-blind there. That is the price of copying a stock theme's
* surfaces exactly, and it is why the tint is designed against the default
* palette's off-white rather than against white.
* CONTRAST IS FREE `l` is copied through, so AA margins barely move. The mix dragged
* lightness toward the anchor: an early attempt cost
* --primary-color-high on --fs-bg its whole margin in hicontrast
@@ -137,9 +146,18 @@
--fs-good-h: 0;
--fs-warn-h: 0;
--fs-danger-h: 0;
/* Tint DENSITY (Appearance → Density), a multiplier on the tint chroma below: 1 = the designed
* strength, 0 = none, up to 2. fs-prefs.js/head.ut set it inline (value/100) when the user
* drags the slider; paired with the hue, it is the second half of the router-identity Tint. */
/* Tint strength (Appearance → Tint strength — NOT the Density select, which is the UI-density
* axis and owns that word), a multiplier on the tint chroma below: 1 = the designed strength,
* up to 2. fs-prefs.js/head.ut set it inline (value/100) when the user drags the slider;
* paired with the hue, it is the second half of the router-identity Tint.
*
* 0 IS NOT "the palette back". The rule below is a relative colour that REPLACES chroma, so a
* multiplier of 0 also strips the cast the canvas already had — every palette's canvas is a
* blue-grey (hue 248-264, chroma .0153 dark / .0034 light, measured below), and at 0 it goes
* neutral at the same lightness: footstrap dark rgb(28,33,40) paints as rgb(33,33,33). Small,
* but it is not the untinted page, and it cannot be made into one here — a conditional would
* have to sit on :root itself, which is the one element a style query cannot reach. The true
* off is clearing the Tint hue, which drops data-tint and this rule with it. */
--fs-tint-strength: 1;
/* The uploaded login/page background (Appearance → Wallpaper → File). --fs-login-bg-url is
* the url() head.ut/fs-prefs.js stamp inline; `none` here is the same stand-on-its-own
@@ -209,9 +227,9 @@
* WHY THE INK IS DERIVED, AND ONLY IN HEX MODE. --fs-on-accent is the ink ON the accent
* fill — a solid button's label, the toggle knob, the active tab pill. The palettes pick it
* per mode against fills they were measured against (white over footstrap light's #0969da is
* 5.19:1; over the dark palette's light fills white collapses to 2.85:1, hence #10151c
* 5.19:1; over the dark palette's light fills white collapses to 2.79:1, hence #10151c
* there). A user-entered colour is measured against nothing, so keeping the palette's ink
* would put white on #ffd400 — 1.2:1, an unreadable button, on the one control whose whole
* would put white on #ffd400 — 1.43:1, an unreadable button, on the one control whose whole
* job is to be pressed.
*
* So in hex mode the ink follows the LIGHTNESS of whatever was entered:
@@ -275,7 +293,7 @@
--fs-good-base: #197c36;
--fs-warn-base: #946300;
--fs-danger-base: #cf222e;
/* ink ON the fills above — white clears AA on all four: 5.19/5.08/4.87/5.36 */
/* ink ON the fills above — white clears AA on all four: 5.19/5.28/5.19/5.36 */
--fs-on-accent: #fff;
--fs-on-good: #fff;
--fs-on-warn: #fff;
@@ -291,15 +309,15 @@
--fs-text: #adbac7;
--fs-dim: #8f9cab;
--fs-faint: #959ca5;
--fs-select-chevron: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="%238c99a8" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>');
--fs-select-chevron: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="%238f9cab" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>');
--fs-accent-base: #569df5;
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent);
--fs-track: #373e47;
--fs-good-base: #5aad5d;
--fs-warn-base: #c79128;
--fs-danger-base: #ea7a74;
/* dark ink: these fills are LIGHT. White measured 2.85/2.85/2.83/3.70 — a fail on
* all four. #10151c (a shade of --fs-bg) gives 6.43/6.44/6.47/4.95. */
/* dark ink: these fills are LIGHT. White measures 2.79/2.77/2.80/2.79 — a fail on
* all four. #10151c (a shade of --fs-bg) gives 6.58/6.61/6.55/6.57. */
--fs-on-accent: #10151c;
--fs-on-good: #10151c;
--fs-on-warn: #10151c;
@@ -364,7 +382,7 @@
--fs-on-danger: #0a0d13;
}
/* ---- classic — the colours of luci-theme-bootstrap, the LuCI default (opt-in) ----
/* ---- bootstrap — the colours of luci-theme-bootstrap, the LuCI default (opt-in) ----
*
* The one palette named after another PACKAGE, because that is what it is: the surfaces,
* greys and semantic colours of the stock theme, so an admin who wants the look they already
@@ -400,7 +418,7 @@
--fs-border-base: #cccccc;
/* THE INK IS THE ONE PLACE THIS COLOURWAY LEAVES BOOTSTRAP'S OWN VALUES BEHIND, and it is
* the same decision the default palette made: bootstrap's #404040 body and #6a6a6a muted
* tier measure 10.4:1 and 5.0:1 on white, and the muted one carries LuCI's page
* tier measure 10.4:1 and 5.4:1 on white, and the muted one carries LuCI's page
* descriptions and every field title — help text you have to lean into. Matched to the
* default palette's light ramp instead, ratio for ratio: 15.7 / 11.7 / 10.0 against its
* 15.8 / 11.8 / 10.0. Neutral greys, so the colourway still reads as bootstrap's — what is
@@ -434,14 +452,19 @@
--fs-panel2-base: #303030;
--fs-border-base: #444444;
--fs-text: #bfbfbf;
/* faint == dim here, and that is the deviation this palette allows itself: bootstrap's dark
* has no third ink, and the export tier's -low now follows --fs-faint (02-tokens.css), so
* the #969696 this used to carry landed at 4.46:1 on a tinted --fs-panel2 — under AA for a
* name apps print text in. The step above it is bootstrap's own, so the fainter one gives
* way rather than the ramp. */
--fs-dim: #9a9a9a;
/* Bootstrap's dark has no third ink — #bfbfbf body, #7f7f7f muted — so the two quiet tiers
* are this palette's own, and AA decides where the fainter one can sit: the export tier's
* -low follows --fs-faint (02-tokens.css), and on a tinted --fs-panel2 the #969696 this
* used to carry measures 4.46:1, under the floor for a name apps print text in. #9a9a9a is
* the first step that clears it, so the FLOOR pins --fs-faint and the step between the
* tiers has to come from above: --fs-dim moves up by the same distance the hicontrast dark
* ramp puts between its own two (12 channel steps), leaving 191 / 166 / 154. Bootstrap's
* muted grey is darker than either, so both are already a deviation; one step more of it
* is cheaper than collapsing --text-color-medium and -low onto one colour, which is the
* thing this revision separates everywhere else. */
--fs-dim: #a6a6a6;
--fs-faint: #9a9a9a;
--fs-select-chevron: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="%239a9a9a" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>');
--fs-select-chevron: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="%23a6a6a6" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>');
--fs-accent-base: #53a4c2;
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent);
--fs-track: #333333;
@@ -61,8 +61,8 @@
flex: 0 0 180px;
/* `end`, not `right`: the label is aligned TOWARDS its field, which is a logical intent.
* NOT the same as base/95-luci.css's `.left/.right/.center` — those are LuCI's forcing
* utilities, emitted 59 times by form.js, and a class named `right` means right in any
* direction. Never sweep those onto start/end. */
* utilities (form.js hands them out at four call sites, eight across luci-base), and a
* class named `right` means right in any direction. Never sweep those onto start/end. */
text-align: end;
}
@@ -190,7 +190,6 @@
button[disabled],
select[disabled],
textarea[disabled],
.cbi-select[disabled]::before,
.cbi-dropdown[disabled]:not(.btn):not(.cbi-button),
input[type="checkbox"][disabled]::before,
input[type="checkbox"][disabled]::after,
@@ -37,10 +37,11 @@
/* AN UNBREAKABLE VALUE MUST NOT LEAVE THE CARD, in ANY .table. Stock has always had this
* (`overflow-wrap: break-word` on every cell); the theme replaced it with a rule that first
* asks what KIND of table it is — theme/30-tables.css breaks long values only for
* `#view .table[id] .td, table.fs-dt .td`, i.e. a table carrying an id or a
* `.tr.table-titles` header row. A third-party status page rendering a key/value
* `<div class="table">` with neither matched nothing, so its cells kept
* asked what KIND of table it is — it broke long values only for `#view .table[id] .td,
* table.fs-dt .td`, i.e. a table carrying an id or a `.tr.table-titles` header row. That
* narrowing is gone (theme/30-tables.css records its removal) and this rule is the only owner
* now. A third-party status page rendering a key/value `<div class="table">` with neither
* matched nothing, so its cells kept
* `overflow-wrap: normal` and one unbroken token — an ICCID, a firmware string, an APN, a
* base64 key, a DUID — held the column open and pushed the table outside its card.
* Measured against luci-app-3ginfo-lite's own markup (`admin/modem`, three such tables).
@@ -101,7 +101,6 @@
.cbi-page-actions .cbi-button-apply + .cbi-button-save,
.cbi-page-actions .cbi-button-negative + .cbi-button-save {
--on-color: var(--fs-good);
border-color: var(--on-color);
/* neutral fill: theme/55-buttons.css — absorbed */
/* fill + border: theme/55-buttons.css — absorbed */
}
}
+14 -3
View File
@@ -55,8 +55,16 @@
* rules' job in theme/30-tables.css, next to the real cells it has to line up with. (Upstream
* declares the border-top here; ours would draw the stray line above the header that issue #7
* removed from every other cell.) */
/* SPLIT OFF THE :has() HALF, and the split is the point: an engine that cannot parse `:has()`
* throws away the WHOLE rule, siblings included — so a Firefox older than 121 lost the two
* plain selectors above with it, and stacked tables came up with no card labels at all. Same
* declarations, two rules, and the half that needs nothing new survives on its own. */
.cbi-section-table-titles.named[data-title]::before,
.cbi-section-table-titles.named[data-title] ~ .cbi-section-table-descr.named::before,
.cbi-section-table-titles.named[data-title] ~ .cbi-section-table-descr.named::before {
content: attr(data-title) " ";
display: table-cell;
vertical-align: middle;
}
.cbi-section-table-row[data-title]:not(:has(> .cbi-section-table-titles))::before {
content: attr(data-title) " ";
display: table-cell;
@@ -379,8 +387,11 @@
#modal_overlay > .modal.login > button.important {
/* no !important needed: the ID makes this (1,3,1), which no button rule can reach */
/* the login dialog's one action, a step up the scale — it was a bare 120% of whatever the
* button happened to be. */
/* Coverage vocabulary, not a live control: nothing in luci-base gives a modal the `login`
* class (ui.showModal takes classes and no call site passes one), and this theme's own
* login is a server-rendered form, not a modal. It is inherited from luci-theme-bootstrap,
* which carries the same selector, so an app that emits it gets a themed result — a step up
* the scale, where it was a bare 120% of whatever the button happened to be. */
font-size: var(--fs-type-lg);
margin-top: var(--fs-space-2);
width: 100%;
@@ -25,20 +25,28 @@
justify-content: center;
border-radius: var(--fs-radius-sm);
}
/* The glyph itself is the Appearance fold's, to the byte — one chevron for "this opens", drawn
* in two page files because the two hosts are a stock element keyed by attribute and one of
* ours keyed by class, which no single selector list in one layer can carry. Pinned instead:
* tools/mirror.mjs fails the build if the copies drift (styles/pages/80-appearance.css). */
.cbi-title .label[data-indicator="poll-status"]::after {
/* @mirror chevron/closed */
content: "";
width: 7px;
height: 7px;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
border-radius: 1px;
transform: rotate(45deg); /* default: chevron down (collapsed / Show) */
transform: rotate(45deg); /* closed: chevron down */
margin-top: -2px; /* optical centring of the down chevron */
transition: transform var(--fs-dur-move);
/* @endmirror */
}
.cbi-title .label[data-indicator="poll-status"][data-style="inactive"]::after {
transform: rotate(-135deg); /* expanded / Hide -> chevron up */
/* @mirror chevron/open */
transform: rotate(-135deg); /* open: chevron up */
margin-top: var(--fs-space-0-5);
/* @endmirror */
}
/* Stock overview layout: fs-overview.js wraps the stock System / Memory / Storage
@@ -59,15 +59,23 @@
* -5 -4 -3 -2 -1
*
* so: the hostname, the IPv6 list and DUID may wrap; IPv4/MAC/IAID and the two short trailing
* columns keep the nowrap above. `.leases` vs `.lases` separates the v4 tables by release on its
* own. The v6 table carries the SAME class on both, so it is split on column COUNT — 24.10 has
* exactly five, 25.12+ has six or seven. That count test is wrapped in `:where()` so it costs
* NOTHING: `:has()` otherwise takes its argument's specificity, and `.tr.table-titles >
* :nth-child(6)` alone pushed the selector to [1,8,1], past the sheet's [1,7,0] ceiling. */
#view table.fs-dt.leases:not(.fs-stacked) .td:is(:nth-last-child(7), :nth-last-child(4)),
* columns keep the nowrap above. BOTH tables are split on column COUNT, never on the release and
* never on the class name: 24.10 emits five columns in each, 25.12 eight in the v4 table and
* seven in the v6 one. The v4 table is also where upstream's `lases` typo lives — fixed on
* 25.12, still there on 24.10 and on ImmortalWrt's fork of it — so keying the shapes off that
* name meant a one-character upstream fix would hand the 24.10 table the 25.12 column plan and
* silently un-fix issue #7. Both spellings are accepted here and the MARKUP decides, which is
* what base/95-luci.css states as the rule for release-conditioned styling.
*
* The count test is wrapped in `:where()` so it costs NOTHING: `:has()` otherwise takes its
* argument's specificity, and `.tr.table-titles > :nth-child(6)` alone pushed the selector to
* [1,8,1], past the sheet's [1,7,0] ceiling. */
#view table.fs-dt:is(.leases, .lases):has(:where(.tr.table-titles > :nth-child(6))):not(.fs-stacked)
.td:is(:nth-last-child(7), :nth-last-child(4)),
#view table.fs-dt.leases6:has(:where(.tr.table-titles > :nth-child(6))):not(.fs-stacked)
.td:is(:nth-last-child(6), :nth-last-child(5), :nth-last-child(4)),
#view table.fs-dt.lases:not(.fs-stacked) .td:nth-last-child(5),
#view table.fs-dt:is(.leases, .lases):not(:has(:where(.tr.table-titles > :nth-child(6)))):not(.fs-stacked)
.td:nth-last-child(5),
#view table.fs-dt.leases6:not(:has(:where(.tr.table-titles > :nth-child(6)))):not(.fs-stacked)
.td:is(:nth-last-child(5), :nth-last-child(4), :nth-last-child(3)) {
/* `white-space` is the whole rule now: it is what re-permits wrapping for these columns
@@ -30,8 +30,9 @@
button.fs-ap-fold:hover h4 { color: var(--fs-accent); }
button.fs-ap-fold:focus-visible { outline: none; box-shadow: var(--fs-focus-ring-solo); border-radius: var(--fs-radius-sm); }
/* The chevron: the same 26px box with a two-border ::after that the overview's card toggles
* use (styles/pages/20-overview.css) — one chevron for "this panel opens", drawn once here and
* once there rather than an SVG in one place and borders in the other.
* use (styles/pages/20-overview.css) — one chevron for "this panel opens", drawn here and there
* rather than an SVG in one place and borders in the other, with the glyph itself pinned as
* @mirror chevron/* so the two copies cannot drift.
*
* It turns on the SAME attribute that tells a screen reader the state, so the two cannot
* disagree. Rotated with `transform` and not the individual `rotate` property, because that is
@@ -42,17 +43,23 @@
color: var(--fs-dim); border-radius: var(--fs-radius-sm);
}
.fs-ap-chev::after {
/* @mirror chevron/closed */
content: "";
width: 7px; height: 7px;
border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
width: 7px;
height: 7px;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
border-radius: 1px;
transform: rotate(45deg); /* closed: chevron down */
margin-top: -2px; /* optical centring of the down chevron */
transition: transform var(--fs-dur-move);
/* @endmirror */
}
button.fs-ap-fold[aria-expanded="true"] .fs-ap-chev::after {
/* @mirror chevron/open */
transform: rotate(-135deg); /* open: chevron up */
margin-top: var(--fs-space-0-5);
/* @endmirror */
}
button.fs-ap-fold:hover .fs-ap-chev { color: var(--fs-accent); }
/* Stock `.cbi-value` rows stack one per line and carry their own hairline, so a folded group needs
@@ -173,8 +180,11 @@
:root[data-wallpaper="file"] .fs-ap-tint { display: none; }
/* and the strength is a multiplier on a chroma WE choose, so it only means anything in hue mode:
* with the tint off there is nothing to scale, and on a hex canvas the colour is the one the
* admin entered, with no chroma of ours in it. (0,3,0) out-ranks the stock row's own display. */
:root:not([data-tint="hue"]) .fs-ap-density { display: none; }
* admin entered, with no chroma of ours in it. (0,3,0) out-ranks the stock row's own display.
* The class was `fs-ap-density` while this axis was called Tint density; the name moved with the
* label, because Density is the UI-density select four rows above and one name for two rows is
* how the label itself went wrong. */
:root:not([data-tint="hue"]) .fs-ap-tintstr { display: none; }
/* the version line at the foot of the page */
.fs-ap-footer {
@@ -189,7 +199,7 @@
/* THE SEGMENTED CONTROL AND THE RANGE SLIDER had their rules here — .fs-seg, .fs-rangewrap,
* .fs-range and its readout. Both controls are gone from fs-widgets.js: the page draws its enums
* with `ui.Select` and its numbers with `ui.RangeSlider`, which this theme already styles for
* every other LuCI form (`select` in base/30-forms.css, `.cbi-range-slider` and
* every other LuCI form (`select` in theme/60-inputs.css, `.cbi-range-slider` and
* `input[type="range"]` in theme/60-inputs.css). Two copies of a slider, one of them ours, was
* the thing to delete — not a third. */
+24 -6
View File
@@ -15,12 +15,24 @@
* normal hostname still breaks at its dots), and it lets the flex item's min-content size
* shrink — which is what stops a long name pushing the menu out of the bar. It used to be
* nowrap + ellipsis, which truncated the one string that identifies the router. */
.fs-wordmark { overflow-wrap: anywhere; min-width: 0; }
/* AND A CEILING ON THE LINES, because the width cap alone bounds the wrong axis. Measured in
* the top bar at 1280px by growing the string: 8 characters give the ordinary 46px bar, 63 (the
* kernel's nodename limit, i.e. the realistic worst case) give 73px, and a hand-set 4000
* character hostname gave a 4273px tall bar with the page content starting below it. Three lines
* is past every real hostname and still a bar; the rest is clipped rather than allowed to push
* the page down. `-webkit-line-clamp` is the only cross-engine spelling of this today and needs
* its two companions, which is why they are here rather than a plain max-height: a max-height cut
* would slice a line in half instead of ending it. */
.fs-wordmark {
overflow-wrap: anywhere; min-width: 0;
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden;
}
/* collapse the sidebar to an icon rail. The toggle only EXISTS in the vertical sidebar,
* so its `display` is declared there, not here, or it would just be shadowed. */
.fs-railtoggle {
align-items: center; justify-content: center;
width: 28px; height: 28px; padding: 0; flex: 0 0 auto;
width: calc(28px * var(--fs-density-box)); height: calc(28px * var(--fs-density-box));
padding: 0; flex: 0 0 auto;
border: 1px solid transparent; border-radius: var(--fs-radius-sm);
background: transparent; color: var(--fs-faint); cursor: pointer;
transition: background var(--fs-dur), border-color var(--fs-dur), color var(--fs-dur);
@@ -554,9 +566,15 @@
* pre-paint, while data-narrow is written later by fitShell() in an async module. So on a
* <521px phone a rail user had every OTHER rail rule excluded by the floor while these
* two matched, and the "Refreshing" pill painted as a bare 30px green glyph inside the
* phone bar until the modules landed — permanently if they failed. */
* phone bar until the modules landed — permanently if they failed.
*
* The box is --fs-ctl-h-sm and not --fs-btn-size: one step under the bar's compact pill,
* because the rail is 68px wide and the glyph sits in a column of icons rather than in a
* row of equal squares. It was a bare 30px, which is the same size at the default density
* and no size at all at the other two — every other chrome box scales with the Density
* axis, and a literal silently opts out of it (02-tokens.css). */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"] {
font-size: 0; width: 30px; height: 30px; padding: 0;
font-size: 0; width: var(--fs-ctl-h-sm); height: var(--fs-ctl-h-sm); padding: 0;
border: 0; box-shadow: none; background: var(--fs-good);
/* @mirror poll-glyph/mask */
-webkit-mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
@@ -579,7 +597,7 @@
* Its label is prose and the rail is 68px: measured on the router, that pill wants 86px,
* wraps onto three lines and hangs 34px past the rail's edge over the content (issue #14).
* The pill is not ours to shorten, so it becomes a square in the poll glyph's box and shows
* the COUNT alone — the only part that changes, lifted into data-fs-count by fs-chrome.js
* the COUNT alone — the only part that changes, lifted into data-fs-badge by fs-chrome.js
* because a text node cannot be reached by a selector. `font-size: 0` kills the prose
* without removing it: the label stays in the DOM for a screen reader, and in the `title`
* for the pointer.
@@ -595,7 +613,7 @@
/* min-width, not width: the count is unbounded (a big config can queue three digits) and
* a fixed box would clip it. It grows from the poll glyph's square and stops at the
* rail's inner width. */
font-size: 0; min-width: 30px; max-width: 100%; height: 30px;
font-size: 0; min-width: var(--fs-ctl-h-sm); max-width: 100%; height: var(--fs-ctl-h-sm);
box-sizing: border-box; padding: 0 var(--fs-space-1); overflow: hidden;
display: inline-flex; align-items: center; justify-content: center;
}
@@ -48,21 +48,37 @@
* `-140.0 dBm`, half again as wide as the `100% (1.2 GiB)` of a table cell. The Overview and the
* package manager keep the value above the bar — their meters sit in a `.td` and a bare
* `.controls` div, which is neither shape. */
.cbi-value-field > .cbi-progressbar,
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar {
/* ONE RULE PER SHAPE, not one selector list for both, and the split is not cosmetic: the table
* half needs `:has()`, and an engine that cannot parse a selector discards the WHOLE rule —
* siblings included. Grouped, the form row (which needs nothing new) lost its placement on such
* an engine along with the cell that was unreachable there anyway. The declarations they share
* are @mirror-pinned instead, so the two copies cannot drift. */
.cbi-value-field > .cbi-progressbar {
/* @mirror meter/beside */
margin-inline-end: var(--fs-meter-value);
/* @endmirror */
--fs-meter-value: calc(var(--fs-space-10) * 2);
}
.cbi-value-field > .cbi-progressbar::after,
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar::after {
.cbi-value-field > .cbi-progressbar::after {
/* @mirror meter/beside-value */
inset-inline: calc(100% + var(--fs-space-2)) auto; bottom: auto; top: 50%;
transform: translateY(-50%);
/* @endmirror */
}
.cbi-value-field > .cbi-progressbar { --fs-meter-value: calc(var(--fs-space-10) * 2); }
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar {
/* @mirror meter/beside */
margin-inline-end: var(--fs-meter-value);
/* @endmirror */
--fs-meter-value: var(--fs-space-10);
/* narrower than this it stops reading as a meter at all; the column gives up the rest. */
min-width: 60px;
}
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar::after {
/* @mirror meter/beside-value */
inset-inline: calc(100% + var(--fs-space-2)) auto; bottom: auto; top: 50%;
transform: translateY(-50%);
/* @endmirror */
}
/* Progressbar rows in a section table (luci-app-cpu-status): keep the native label | bar
* row, just centre the cells vertically. */
@@ -65,11 +65,11 @@
* one place a field cannot claim the width it wants: it lives in a table HEADER cell, so the
* column is as wide as the DATA under it, not as wide as the box. The rule above already makes
* it fill that cell — what did not fit was the TEXT. LuCI writes the placeholder as `Filter ` +
* the column title, and the theme's field is 13px type inside 11px of side padding, so on a
* the column title, and the theme's field is 13px type inside 12px of side padding, so on a
* Russian firewall page "Фильтр Внутризональная пересылка" ran out of box and Firefox sliced it
* mid-glyph (issue #12). The flag column is the worse half: form.js writes `width: 30px` INLINE
* there (its filter takes one character), of which the theme's padding ate 22 — six pixels for
* "0/1", which rendered as a single clipped digit.
* there (its filter takes one character), of which the theme's padding ate 24 — leaving less
* than one glyph of room for "0/1", which rendered as a single clipped digit.
*
* Denser box and denser type buy back ~20px per column and make the 30px one legible; the
* ellipsis is what changes the FAILURE: a placeholder too long for its column now ENDS, instead
@@ -144,8 +144,9 @@
* DANGER role, not the accent one: Reset discards your unsaved changes. (An opaque
* --fs-panel2 was the first attempt and weighed MORE than Save's translucent 10-15% tint: a
* solid surface and a tint do not read as the same fill, whatever their lightness.)
* Label is --fs-text for the same reason as Save's: --fs-dim on --fs-danger-soft measured
* 4.06:1 and failed AA. The role lives in the fill and the border. */
* Label is --fs-text for the same reason as Save's: --fs-dim on --fs-danger-soft measures
* 4.49:1 on --fs-panel in footstrap dark, the worst of the six palette x mode blocks — under
* AA by a hundredth, which is still under it. The role lives in the fill and the border. */
.cbi-page-actions .cbi-button-reset {
background: var(--fs-danger-soft);
border: 1px solid var(--fs-danger-line);
@@ -31,7 +31,7 @@
transition: border-color var(--fs-dur), box-shadow var(--fs-dur);
}
/* keep base's padding-top: 6px — it is tuned for base's align-items: baseline. */
/* keep base's padding-top (--fs-space-1) — it is tuned for base's align-items: baseline. */
.cbi-value-title {
color: var(--fs-dim); font-size: var(--fs-type); font-weight: var(--fs-weight);
/* stated, not inherited: the control rule above hands every label `line-height: normal`,
@@ -65,7 +65,8 @@
/* A text field in a CBI form was a fixed 210px — base's width, straight out of bootstrap's
* cascade. The same 210px holds far LESS here: the field is monospaced (the rule above) and
* padded 11px a side instead of 4, so Attended Sysupgrade's server URL was clipped mid-domain
* padded --fs-space-3 a side (12px at the default density) instead of 4, so Attended
* Sysupgrade's server URL was clipped mid-domain
* — while the .cbi-dynlist directly under it on that same page is 440px wide, which is what
* makes the mismatch the thing you actually see (issue #5). Elastic now, with the dynlist's own
* ceiling so the two line up. Number/date/time keep the fixed width: they hold short values,
@@ -513,10 +514,6 @@
line-height: normal;
cursor: pointer;
}
/* The ▾ plate is gone: the chevron is a background on the shell, exactly as on `select`. Hidden
* rather than deleted because base/30-forms.css still names `.cbi-select[disabled]::before` in
* its disabled list — dropping the rule here would leave that one to paint a bare glyph. */
.cbi-select::before { display: none; }
/* spinner / loading */
.spinning, .cbi-value em, em.spinning { color: var(--fs-dim); }
@@ -1,6 +1,9 @@
@layer theme {
/* Generic .dropdown-menu popup — ui.js's split "Save & Apply" menu, plus whatever a third-party
* luci-app-* emits. NOT the theme's nav menu (that is a plain nested <ul>; see 10-chrome.css).
/* Generic .dropdown-menu popup. NOTHING in luci-base emits it — the split "Save & Apply" button
* is a `.cbi-dropdown`, a different widget — so this is coverage vocabulary: stock
* luci-theme-bootstrap styles it, a third-party luci-app-* may therefore emit it and expect a
* themed result (the same contract 10-chrome.css states for the same class). NOT the theme's
* nav menu (that is a plain nested <ul>; see 10-chrome.css).
* Hover text is ACCENT to match the open cbi-dropdown list: this is a form control's menu. */
.dropdown-menu { background-color: var(--fs-panel); border-color: var(--fs-border); }
.dropdown-menu a { color: var(--fs-text); text-shadow: none; }
@@ -284,7 +287,7 @@
font-size: 0; line-height: 0; border: 0;
position: relative; padding: 0;
align-self: stretch;
min-width: 34px; min-height: var(--fs-ctl-h-sm);
min-width: var(--fs-btn-size); min-height: var(--fs-ctl-h-sm);
align-items: center;
cursor: pointer;
}
@@ -33,10 +33,12 @@
overscroll-behavior-x: contain;
}
/* Must stay INSIDE this @media: written unscoped it never took effect (base's
* .cbi-dropdown:not(.btn):not(.cbi-button) is more specific and kept its 400px cap), and
* with base in its own layer it would now cap every dropdown on the desktop too. A
* dropdown must never exceed its field, on a page or in a dialog. */
/* Must stay INSIDE this @media, and the reason is specificity within ONE layer: the 400px
* cap lives in theme/65-dropdown.css (absorbed there from base) on
* `.cbi-dropdown:not(.btn):not(.cbi-button)` — (0,3,0) against this rule's (0,1,0) — so
* unscoped this would lose to it everywhere and cap nothing at all. Inside the phone tier
* it is what the narrow width needs: a dropdown must never exceed its field, on a page or
* in a dialog. */
.cbi-dropdown { min-width: 0; max-width: 100%; }
/* Software page (luci-app-package-manager): stack each labeled control; base gives its
@@ -53,8 +53,15 @@
* text's contrast; pull it toward --fs-text. The role hairlines (-line/-line-hi, a 40/55%
* tint of their role) go to the FULL role colour — a tint exists to be quiet, which is
* the opposite of what this user asked for.
* - secondary text: --fs-dim toward --fs-text, and --fs-faint (10-11px eyebrow labels)
* up to dim's level — small + faint is where AA erodes first.
* - secondary text: --fs-dim toward --fs-text, and --fs-faint most of the way there too
* (10-11px eyebrow labels: small + faint is where AA erodes first). NOT all the way onto
* --fs-dim, which is what this used to say: the two are the export tier's --text-color-
* medium and -low (02-tokens.css), so collapsing them here republished one colour twice to
* every app reading the gradation — the exact flattening that file spends twelve lines
* undoing. 82% against dim's 85% is inside a narrow window, and export-tier.mjs now measures
* this media query rather than taking it on trust: below 80% the fainter ink drops under AA
* on footstrap dark's --fs-panel2 (4.51:1 at 80), above 82% the high-to-low ramp falls under
* the 0.10 spread the tier promises (0.098 at 84). 82 clears both: 4.68:1 and 0.110.
* - the focus ring: --fs-focus-ring is a tint of the accent (--fs-accent-soft: 10% light,
* 15% dark — the one ladder member whose strength is per MODE, so not the ladder's 12%);
* at "more contrast" it carries the accent at 60%. Its sibling --fs-focus-ring-solo is
@@ -64,7 +71,7 @@
:root {
--fs-border: color-mix(in srgb, var(--fs-text) 45%, var(--fs-bg));
--fs-dim: color-mix(in srgb, var(--fs-text) 85%, var(--fs-bg));
--fs-faint: var(--fs-dim);
--fs-faint: color-mix(in srgb, var(--fs-text) 82%, var(--fs-bg));
--fs-focus-ring: 0 0 0 3px color-mix(in srgb, var(--fs-accent) 60%, transparent);
--fs-accent-line: var(--fs-accent); --fs-accent-line-hi: var(--fs-accent);
--fs-good-line: var(--fs-good); --fs-good-line-hi: var(--fs-good);
@@ -106,6 +113,16 @@
background: Highlight;
forced-color-adjust: none; /* keep the fill: it IS the state */
}
/* WHICH PAGE AM I ON. The menu's active item carries its state in exactly the two things
* forced colors drops: a 10%-alpha accent background and the box-shadow rail. Measured with
* the query emulated — all twelve #topmenu links reported the same weight, the same absent
* shadow, the same absent border and the same LinkText colour, so the current page was not
* marked at all. `aria-current="page"` is on the link (menu-footstrap.js), so this was a
* sighted-user loss only. The rail comes back as a real border, which forced colors keeps. */
#topmenu li.active > a {
border-inline-start: 3px solid Highlight;
padding-inline-start: calc(var(--fs-space-3) - 3px);
}
/* the toggle switch is a bare rounded box in forced colors — give the knob and
* the track an edge so on/off is distinguishable at all */
.cbi-checkbox > label { border: 1px solid ButtonBorder; }
@@ -14,9 +14,13 @@
So every label in the Appearance page carries the `footstrap` msgctxt (fs-appearance.js), while
the CHROME's own strings — Skip to content, Menu, Collapse menu, Search, Log out — are
deliberately msgctxt-FREE: inheriting luci-base's translation is a feature in the ~40 languages
this theme ships no catalogue for. Same for the login/notice sentences and for
System/Memory/Storage in fs-overview.js, which MATCH the stock headings on purpose.
deliberately msgctxt-FREE, because a bare msgid is how a string INHERITS a translation in the ~40
languages this theme ships no catalogue for. Two of the five collect on that today: Skip to
content and Log out are luci-base msgids verbatim (the latter was `Logout`, which is nobody's
msgid, so it rendered in English everywhere our own catalogue does not reach). Menu, Collapse menu
and Search are ours alone for now and stay bare so a future luci-base string can adopt them. Same
for the login/notice sentences and for System/Memory/Storage in fs-overview.js, which MATCH the
stock headings on purpose.
-#}
{%
@@ -74,8 +78,14 @@
fs_defaults[k] = fsd[k] || '';
/* the one value with a fallback of its own: the legacy migration seed
luci.main.footstrap_layout=top, which uci-defaults sets for a router upgraded from the old
top-nav theme (a shell script cannot write localStorage). */
fs_defaults.layout = fsd.layout || config.main.footstrap_layout || '';
top-nav theme (a shell script cannot write localStorage).
`config.main?.` and not `config.main.`: ucode raises a hard reference error on a property read
through null and this template is the ONE that renders every page, so a dispatcher that stopped
defaulting that section would answer 500 everywhere. It defaults it today — dispatcher.uc has
`main: uci.get_all('luci', 'main') ?? {}` on both release branches — and the CI gate cannot see
the difference either way: `ucode -T -c` COMPILES the template, it never runs it. */
fs_defaults.layout = fsd.layout || config.main?.footstrap_layout || '';
http.prepare_content('text/html; charset=UTF-8');
-%}
@@ -12,8 +12,12 @@
break the inline script nor inject markup (defence in depth — writing that file already needs
root). Absent/invalid becomes '' (or 0, or -1 for rounding whose 0 is a real value), which the
JS reads as "no router default". The client's own localStorage overrides every one of these
before paint — the browser choice always wins (see fs-prefs.js). */
fs_defaults = fs_defaults ?? {};
before paint — the browser choice always wins (see fs-prefs.js).
`fs_defaults` is a required include parameter, not an optional one: header.ut is the only
caller and builds it as an object literal before the include, so there is nothing to default
it to — a `?? {}` here only hid that contract from a second caller that would need to honour
it. */
let _sd_lay = index([ 'sidebar', 'top' ], fs_defaults.layout) >= 0 ? fs_defaults.layout : '';
let _sd_dark = index([ 'auto', 'light', 'dark' ], fs_defaults.darkmode) >= 0 ? fs_defaults.darkmode : '';
let _sd_pal = index([ 'footstrap', 'hicontrast', 'bootstrap' ], fs_defaults.palette) >= 0 ? fs_defaults.palette : '';
@@ -61,8 +65,9 @@
builds the url() below; anything else becomes '' = "no background". */
let _lb = fs_defaults.login_bg;
let _sd_lbg = (type(_lb) == 'string' && match(_lb, /^[a-f0-9]{6,64}$/)) ? _lb : '';
/* the router-identity Tint's DENSITY 0-200 (% of the designed chroma); '' (unset) -> -1, like
rounding, because 0 is a real value (no tint). */
/* the router-identity Tint's STRENGTH 0-200 (% of the designed chroma; the Appearance row is
"Tint strength" — "Density" is the other axis entirely). '' (unset) -> -1, like rounding,
because 0 is a real value. */
let _ts = int(fs_defaults.tint_strength);
let _sd_tstr = (fs_defaults.tint_strength != '' && _ts >= 0 && _ts <= 200) ? _ts : -1;
/* the File photo's scrim opacity 0-100 (Wallpaper -> File -> Dim), a SHARED router value; unset
@@ -150,7 +155,7 @@
RTL set rather than only the three LuCI ships catalogues for today (ar, fa, he), because the
cost of a language never installed is one array entry. `ltr` is written explicitly for the same
reason data-layout is: every rule then matches positively. */
let _lang = dispatcher.lang ?? 'en';
let _lang = dispatcher.lang;
let _lbase = lc(split(_lang, /[-_]/)[0]);
let _rtl = index([ 'ar', 'arc', 'ckb', 'dv', 'fa', 'ha', 'he', 'khw', 'ks', 'ps', 'sd', 'ur', 'yi' ], _lbase) >= 0;
%}
@@ -226,14 +231,28 @@
(this browser) wins; absent -> the router default in window.__fsSD; absent -> the
built-in. */
(() => {
/* PER READ, not once around the block. A browser that refuses storage (Firefox with
dom.storage.enabled=false, "block all cookies" in Chrome/Safari, several WebViews)
throws a SecurityError on the FIRST getItem, and with one try around everything that
aborted the whole pre-paint: dark mode still applied — it guards narrowly, three
blocks up — while palette, density, rounding, wallpaper, the pattern axes, the five
colour axes, the four surfaces, tint strength and photo dim were all skipped, so a
router that had saved defaults painted none of them. Storage failing must mean "this
browser has no override", which is exactly what the live applier's lsGet already
means (fs-prefs.js). The outer try stays as the last resort for everything else. */
const lsGet = (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } };
try {
const root = document.querySelector(':root'),
sd = window.__fsSD || {};
let p = localStorage.getItem('fs-palette');
let p = lsGet('fs-palette');
/* legacy 'rvht'/'roman' = default colours + a doodle wallpaper, and legacy
'github' = the footstrap default, now stored explicitly. Migrate to the current
explicit value, do NOT clear (an absent key means "inherit the router default",
which is no longer the same as "the built-in default"). The doodles they used to
which is no longer the same as "the built-in default"). The migration is
already redundant, and worth knowing before it is carried forward forever: a
stray value reads as the built-in default in the live applier too
(fs-prefs.js's currentPalette ends `return 'footstrap'`), so deleting this
block changes nothing UNLESS that line goes with it. The doodles they used to
turn on are gone — the Wallpaper axis now paints an SVG the admin uploads — so
the wallpaper half of that migration has nothing to point at and is dropped;
`fs-wallpaper` keeps whatever it holds and the whitelist below reads a doodle
@@ -256,18 +275,18 @@
whitelisted to hex above, so nothing user-controlled reaches the url(). */
const pu = sd.pattern;
if (pu) root.style.setProperty('--fs-pattern-url', 'url("/luci-static/footstrap/pattern.svg?v=' + pu + '")');
const psRaw = localStorage.getItem('fs-pattern-size');
const psRaw = lsGet('fs-pattern-size');
const ps = (psRaw !== null) ? parseInt(psRaw, 10) : sd.pattern_size;
if (ps >= 40 && ps <= 1600 && ps !== 440) root.style.setProperty('--fs-pattern-size', ps + 'px');
const pstRaw = localStorage.getItem('fs-pattern-strength');
const pstRaw = lsGet('fs-pattern-strength');
const pst = (pstRaw !== null) ? parseInt(pstRaw, 10) : sd.pattern_strength;
if (pst >= 0 && pst <= 100 && pst !== 20) root.style.setProperty('--fs-pattern-strength', String(pst / 100));
let pi = localStorage.getItem('fs-pattern-ink');
let pi = lsGet('fs-pattern-ink');
if (pi === null) pi = sd.pattern_ink || null;
if (pi === 'original') root.setAttribute('data-pattern-ink', 'original');
else root.removeAttribute('data-pattern-ink');
let w = localStorage.getItem('fs-wallpaper');
let w = lsGet('fs-wallpaper');
if (w === null) w = sd.wallpaper || null;
if (w === 'pattern' || w === 'file') root.setAttribute('data-wallpaper', w);
else root.removeAttribute('data-wallpaper');
@@ -276,7 +295,7 @@
type and space ladders). Normal is a bare :root, so the removeAttribute
half matters as much as the set — without it a browser that switched back
to Normal would paint one frame still compressed. */
let dn = localStorage.getItem('fs-density');
let dn = lsGet('fs-density');
if (dn === null) dn = sd.density || null;
if (dn === 'compact' || dn === 'large') root.setAttribute('data-density', dn);
else root.removeAttribute('data-density');
@@ -285,7 +304,7 @@
default is a LITERAL: this runs before the stylesheet and cannot read the
CSS token, so axes.mjs is the only thing checking the two agree. sd.rounding
is -1 when the router has no default. */
const rRaw = localStorage.getItem('fs-radius');
const rRaw = lsGet('fs-radius');
let r = parseInt(rRaw, 10);
if (rRaw === null) r = (sd.rounding >= 0 ? sd.rounding : 12);
if (r >= 0 && r <= 20 && r !== 12)
@@ -302,7 +321,7 @@
to one shape. */
const hex = /^#[0-9a-f]{6}$/i;
const colour = (key, sdVal, attr, hueProp, colorProp) => {
const raw = localStorage.getItem(key);
const raw = lsGet(key);
const v = String((raw === null ? sdVal : raw) ?? '').trim();
if (hex.test(v)) {
root.style.removeProperty(hueProp);
@@ -327,7 +346,7 @@
mode and therefore no attribute, just the token or nothing. Mirrors
surfaceAxis() in fs-prefs.js. */
const surface = (key, sdVal, prop) => {
const raw = localStorage.getItem(key);
const raw = lsGet(key);
const v = String((raw === null ? sdVal : raw) ?? '').trim().toLowerCase();
if (hex.test(v)) root.style.setProperty(prop, v);
};
@@ -339,19 +358,22 @@
window.__fsSD — no localStorage). Set the property whenever a valid token
exists; whether it PAINTS is the wallpaper axis above (data-wallpaper="file"),
so a router whose default wallpaper is 'file' shows it here, pre-login included.
Re-validate the token (defence in depth, same charset as the server sanitiser). */
The token is already whitelisted to hex above, the same as sd.pattern before it, so
nothing user-controlled reaches the url(); the test is repeated here anyway
because this one interpolates into a url() inside a STYLE property, where a
second reading costs one regex and the failure would be silent. */
const lb = sd.login_bg;
if (lb && (/^[a-f0-9]{6,64}$/).test(lb))
root.style.setProperty('--fs-login-bg-url', "url('/luci-static/footstrap/bg?v=" + lb + "')");
/* the router-identity Tint's density (default 100% = the designed chroma). */
const tsRaw = localStorage.getItem('fs-tint-strength');
const tsRaw = lsGet('fs-tint-strength');
let ts = parseInt(tsRaw, 10);
if (tsRaw === null) ts = (sd.tint_strength >= 0 ? sd.tint_strength : 100);
if (ts >= 0 && ts <= 200 && ts !== 100)
root.style.setProperty('--fs-tint-strength', String(ts / 100));
/* the File photo's scrim opacity — an ordinary per-browser axis, same shape as
the tint strength above; the 74% default lives in the CSS token. */
const pdRaw = localStorage.getItem('fs-photo-dim');
const pdRaw = lsGet('fs-photo-dim');
let pd = parseInt(pdRaw, 10);
if (pdRaw === null) pd = (sd.photo_dim >= 0 ? sd.photo_dim : 74);
if (pd >= 0 && pd <= 100 && pd !== 74)
@@ -444,9 +466,10 @@
lang=en it spends them to deliver 13 bytes (`window.TR={};`): there is no en catalogue,
because the msgids already ARE English. The process is the cost, not the data. So emit
those 13 bytes inline when this language has no catalogue, and keep the tag when it has.
The probe MIRRORS the server: action_translations calls load_catalog(lang,
'/usr/lib/lua/luci/i18n'), which loads every `*.<lang>.lmo` there — so a router that does
ship an en catalogue still gets the tag. Deciding by language NAME would silently drop it.
The probe MIRRORS the server: determine_request_language() (dispatcher.uc) loads this
request's catalogue with load_catalog(lang, '/usr/lib/lua/luci/i18n'), which takes every
`*.<lang>.lmo` in that directory — so a router that does ship an en catalogue still gets
the tag. Deciding by language NAME would silently drop it.
Fails OPEN (a throwing probe keeps the tag): a missing catalogue makes every `_()` render
English and report nothing, which is the one failure this must never risk. #}
{%- let has_catalog = true;
@@ -8,8 +8,8 @@
Licensed to the public under the Apache License 2.0.
-#}
{% if (dispatcher.lookup("admin/logout")): %}
<a class="fs-logout" href="{{ dispatcher.build_url('admin/logout') }}" title="{{ _('Logout') }}" aria-label="{{ _('Logout') }}">
<a class="fs-logout" href="{{ dispatcher.build_url('admin/logout') }}" title="{{ _('Log out') }}" aria-label="{{ _('Log out') }}">
{% include('themes/footstrap/partials/icon', { body: '<path d="M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4"/><path d="M10 12H3m0 0 3-3m-3 3 3 3"/>' }) %}
<span class="fs-label">{{ _('Logout') }}</span>
<span class="fs-label">{{ _('Log out') }}</span>
</a>
{% endif %}
@@ -1,9 +1,11 @@
{#
The page-search trigger (#fs-search-btn), wired by fs-search.js. It is a `.fs-themerow` — the
SAME class the Appearance trigger uses, not a look of its own — so it is one of the right
chrome's shared row/button class rather than a look of its own — so it is one of the right
cluster's square icon buttons in the bar and the rail, and a full-width labelled row in the
vertical sidebar, with no new CSS to keep in step. It sits between the poll pill (#indicators)
and Appearance: those three share `order: 2`, so the DOM order here IS the visual order.
and Log out, which share `order: 2` with it, so the DOM order here IS the visual order. (The
Appearance trigger was the third member until the panel became a tab on System -> System; this is
the only .fs-themerow the document carries now.)
i18n: 'Search' is CHROME and deliberately carries no msgctxt — inheriting luci-base's translation
is a feature in the ~40 languages this theme ships no catalogue for. The palette's OWN strings
@@ -5,10 +5,13 @@
Copyright 2008-2012 Jo-Philipp Wich <jow@openwrt.org>
Licensed to the public under the Apache License 2.0.
It exists to pass `blank_page: true`, the only thing it changes about the generic template:
without a theme-local sysauth the dispatcher falls back to that one, which includes the header
WITHOUT blank_page — so the whole chrome (sidebar, menu, footer) rendered around the login
form, every control on it dead.
It exists because the generic template includes the header WITHOUT `blank_page: true`: with no
theme-local sysauth the dispatcher falls back to that one and the whole chrome (sidebar, menu,
footer) rendered around the login form, every control on it dead. Everything else here is a
DEPARTURE the notes below each justify — the `fs-login` hook the page CSS needs, the id/for/
autocomplete attributes, the alert gating, and an HTTPS-redirect script that also fixes the
generic's `split(https_port, …)` typo — and the rule is that none of them may drop a field the
backend posts.
It is NOT a copy of bootstrap's sysauth.ut, which hides the form in a `<section hidden>` and
needs a view module to reveal it: tried here once and it gave a BLANK PAGE WITH NO WAY TO LOG
@@ -88,8 +91,8 @@
sysauth that drops them is not a login form with a missing extra: the field is never
posted, so on a router with 2FA enabled NOBODY CAN LOG IN while this theme is active,
and the backend's "Too many failed attempts" / "System time is not calibrated" messages
never reach the screen either. This template exists only to pass `blank_page: true`, so
it must stay a superset of the generic one.
never reach the screen either. Whatever else this template changes, it must stay a
SUPERSET of the generic one's fields.
The leading space inside each conditional attribute is load-bearing. ucode's template
lexer eats the whitespace immediately BEFORE a `{%` tag and the newline immediately
+5 -5
View File
@@ -5,8 +5,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=smartdns
PKG_VERSION:=48.2
PKG_RELEASE:=4
PKG_VERSION:=48.4
PKG_RELEASE:=5
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/pymumu/smartdns/tar.gz/Release$(PKG_VERSION)?
@@ -73,9 +73,9 @@ endef
define Download/smartdns-webui
PROTO:=git
URL:=https://github.com/pymumu/smartdns-webui.git
SOURCE_DATE:=2026-03-31
SOURCE_VERSION:=c0d2411d20bf8b172a045adbe07166df6691e58b
MIRROR_HASH:=fa9463e912169e8907f9454c186c7bcb0b3a36c86a522e065c98a7ea38fcecd8
SOURCE_DATE:=2026-07-29
SOURCE_VERSION:=1c06fee693434ec7c71f06010d3f87416baba9f0
MIRROR_HASH:=1b718d1288f6ddbdd80e3a948dda1693fd7bc8079fdb65be121f64f23ce53849
SUBDIR:=smartdns-webui-$$$$(subst -,.,$$$$(SOURCE_DATE))~$$$$(call version_abbrev,$$$$(SOURCE_VERSION))
FILE:=$$(SUBDIR).tar.zst
endef