Compare commits

..

1 Commits

Author SHA1 Message Date
Ilia Ross
ad2a1dc44a Fix global ACL cleanup when deleting groups
This PR fixes deleted groups leaving their global ACL file behind.

It removes that file alongside the group's module ACL files. Recreating a group with the same name no longer restores its old global permissions.
2026-10-23 20:28:25 +02:00
10 changed files with 45 additions and 280 deletions

View File

@@ -1183,7 +1183,9 @@ my $lref = &read_file_lines("$config_directory/webmin.groups");
@$lref = grep { !/^([^:]+):/ || $1 ne $groupname } @$lref;
&flush_file_lines();
&unlock_file("$config_directory/webmin.groups");
&unlink_file(map { "$config_directory/$_/$groupname.gacl" } &list_modules());
# Remove global permissions too, so reusing the name cannot revive old ACLs.
&unlink_file(map { "$config_directory/$_/$groupname.gacl" }
("", &list_modules()));
if ($miniserv{'userdb'}) {
# Also delete from group database

View File

@@ -90,25 +90,18 @@ my @mcan = $access{'gassign'} eq '*' ?
( ( map { $_->{'name'} } @glist ), '_none' ) :
split(/\s+/, $access{'gassign'});
my %gcan = map { $_, 1 } @mcan;
# Find the user's group even when this editor cannot change it.
my $memg;
foreach my $g (@glist) {
if (&indexof($user{'name'}, @{$g->{'members'}}) >= 0 ||
$in{'clone'} &&
&indexof($in{'clone'}, @{$g->{'members'}}) >= 0) {
# A clone starts in the same group as its source user.
$memg = $g;
}
}
# Show the group selector when this editor may change membership.
if (@glist && %gcan) {
my @opts = ( );
if ($gcan{'_none'}) {
# Allow leaving a group only when the editor has permission.
push(@opts, [ undef, "<$text{'edit_none'}>" ]);
}
# Keep the current group available even if it cannot be newly assigned.
foreach my $g (@glist) {
if (&indexof($user{'name'}, @{$g->{'members'}}) >= 0 ||
$in{'clone'} &&
&indexof($in{'clone'}, @{$g->{'members'}}) >= 0) {
$memg = $g;
}
next if (!$gcan{$g->{'name'}} && $memg ne $g);
push(@opts, [ $g->{'name'} ]);
}
@@ -382,8 +375,8 @@ elsif ($miniserv{'twofactor_provider'}) {
&ui_submit($text{'edit_twofactoradd'}, "twofactor"));
}
# RPC is a global permission and cannot be edited on a group member.
if ($access{'acl'} && !$memg->{'name'} && !$safe) {
# Can accept RPC calls?
if ($access{'acl'} && !$safe) {
print &ui_table_row(&hlink($text{'acl_rpc'}, 'rpc'),
&ui_radio("rpc", int($uaccess{'rpc'}),
[ [ 1, $text{'acl_rpc1'} ],
@@ -469,9 +462,9 @@ print &ui_table_row(undef, &ui_links_row(\@links).
&ui_links_row(\@links), 2);
print &ui_hidden_table_end("mods");
# Group members inherit global permissions, even without a local ACL file.
# Hide these fields because save_user.cgi ignores individual edits.
if ($access{'acl'} && !$memg->{'name'} && !$safe) {
# Add global ACL section, but only if not set from the group
my $groupglobal = $memg && -r "$config_directory/$memg->{'name'}.acl";
if ($access{'acl'} && !$groupglobal && !$safe) {
print &ui_hidden_table_start($text{'edit_global'}, "width=100%", 2,
"global", 0, [ "width=30%" ]);
print &ui_hidden("acl_security_form", 1);

View File

@@ -23,10 +23,12 @@
--radius: 8px;
--radius-sm: 6px;
--control-height: 32px;
/* Shared gap around the menu and between cards, buttons and blocks.
* Light surfaces use a slightly tighter gap than dark surfaces.
* The page bottom keeps scroll room under the last element. */
--card-gap: 4px;
/* Room between cards, buttons and blocks, and the same room around a
* page at the top and the sides; the bottom keeps scroll room under
* the last element */
--card-gap: 10px;
--page-pad-x: var(--card-gap);
--page-pad-top: var(--card-gap);
--page-pad-bottom: 28px;
/* Titles on the ground sit a little in from the card edge, so they
* neither touch the edge nor go as deep as the text inside the
@@ -80,8 +82,6 @@
--accent-color-hover: #234d93;
--accent-color-soft: #a9c0e8;
--element-highlight-bg-color: #e3ebf8;
/* Navigation loader, with a separate shade for each color scheme. */
--menu-loader-color: #dc143c;
/* Text on a button filled with a notice color: white on the deep
* light-mode colors, the ground color on the bright dark-mode ones.
* The dark palette keeps its own amber for the warning button. */
@@ -126,7 +126,6 @@
@media (prefers-color-scheme: dark) {
html:not([data-scheme="light"]) body:not([data-scheme="light"]) {
color-scheme: dark;
--card-gap: 7px;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-color: rgba(0, 0, 0, 0.4);
--border-color-panel: #30353d;
@@ -160,7 +159,6 @@
--accent-color-hover: #5a8ad8;
--accent-color-soft: #3d5f8a;
--element-highlight-bg-color: #26395a;
--menu-loader-color: #c7302c;
--alert-button-text: var(--bg-color);
--alert-button-warning: #e0a63c;
--alert-button-warning-text: var(--bg-color);
@@ -204,7 +202,6 @@
}
html[data-scheme="dark"] body, body[data-scheme="dark"] {
color-scheme: dark;
--card-gap: 7px;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-color: rgba(0, 0, 0, 0.4);
--border-color-panel: #30353d;
@@ -238,7 +235,6 @@ html[data-scheme="dark"] body, body[data-scheme="dark"] {
--accent-color-hover: #5a8ad8;
--accent-color-soft: #3d5f8a;
--element-highlight-bg-color: #26395a;
--menu-loader-color: #c7302c;
--alert-button-text: var(--bg-color);
--alert-button-warning: #e0a63c;
--alert-button-warning-text: var(--bg-color);
@@ -297,9 +293,6 @@ html {
background: transparent;
}
body {
/* Resolve page insets here so they follow the body's color scheme. */
--page-pad-x: var(--card-gap);
--page-pad-top: var(--card-gap);
margin: 0;
padding: var(--page-pad-top) var(--page-pad-x) var(--page-pad-bottom);
/* The core header sets height:100% inline on html and body, which
@@ -1697,16 +1690,16 @@ td:not(:hover) .ui_link_help { visibility: hidden; }
.ui_text_wrap { float: right; }
/* Tooltips from the aria-label of elements marked data-tooltip: a dark
* box with an arrow above the element, shown on hover */
[data-tooltip][aria-label]:not(.ui_tip) {
[data-tooltip][aria-label] {
position: relative;
vertical-align: top;
}
/* Card actions align help bubbles through ui-lib, but this theme still
* needs the bubble to anchor its generated tooltip. */
.ui_card_actions .ui_help[data-tooltip][aria-label]:not(.ui_tip) {
.ui_card_actions .ui_help[data-tooltip][aria-label] {
position: relative;
}
[data-tooltip][aria-label]:not(.ui_tip)::after {
[data-tooltip][aria-label]::after {
content: "";
position: absolute;
bottom: 100%;
@@ -1724,7 +1717,7 @@ td:not(:hover) .ui_link_help { visibility: hidden; }
/* Keep a tooltip near the top above the sticky page header. */
z-index: 10;
}
[data-tooltip][aria-label]:not(.ui_tip)::before {
[data-tooltip][aria-label]::before {
content: attr(aria-label);
position: absolute;
bottom: calc(15px + 100%);
@@ -1747,8 +1740,8 @@ td:not(:hover) .ui_link_help { visibility: hidden; }
visibility: hidden;
z-index: 10;
}
[data-tooltip][aria-label]:not(.ui_tip):hover::before,
[data-tooltip][aria-label]:not(.ui_tip):hover::after {
[data-tooltip][aria-label]:hover::before,
[data-tooltip][aria-label]:hover::after {
visibility: visible;
opacity: 1;
transition-duration: 0.5s;
@@ -1756,7 +1749,7 @@ td:not(:hover) .ui_link_help { visibility: hidden; }
/* Opacity gives the tooltip trigger its own stacking context. Raise that
* context while the tooltip is visible, because raising its children alone
* cannot place them above the sticky header. */
[data-tooltip][aria-label]:not(.ui_tip):hover {
[data-tooltip][aria-label]:hover {
z-index: 10;
}
@@ -1819,7 +1812,7 @@ br[data-x-br] {
/* ---- Left frame (left.cgi) ---- */
/* The frame shows the page ground, and the menu floats on it as a card
* with the shared gap at the top, the left and the bottom, and rounder
* with a 7px gap at the top, the left and the bottom, and rounder
* corners than the other cards. At the right the card meets the frame
* edge, so the page padding of the right frame is the whole gap between
* the card and the content, and a page opened in a window of its own
@@ -1829,7 +1822,7 @@ br[data-x-br] {
body.left-frame {
box-sizing: border-box;
height: 100vh !important;
padding: var(--card-gap) 0 var(--card-gap) var(--card-gap);
padding: 7px 0 7px 7px;
overflow: hidden;
background: var(--bg-color);
}
@@ -1874,9 +1867,11 @@ html.loading-right .menu-loader { display: block; }
width: 35%;
height: 100%;
border-radius: 2px;
background: var(--menu-loader-color);
background: var(--accent-color);
animation: menu-bounce 0.9s ease-in-out infinite alternate;
}
/* Match Authentic's crimson progress bar for Cloudmin navigation. */
.menu-panel-cloudmin .menu-loader::before { background: #dc143c; }
@keyframes menu-bounce { to { left: 65%; } }
/* The brand block: the logo has as much room above it as below it, and
* the logo files are trimmed to their drawing, so every product sits the

View File

@@ -98,7 +98,7 @@ assert_no_handler_injection(main::ui_tip('<b>x</b>', $xss), 'ui_tip');
my $tip = main::ui_tip('<b>x</b>', 'A <i>tip</i>');
like($tip, qr/^<span (?=[^>]*\bclass="ui--span ui_tip")(?=[^>]*\baria-label="A tip")(?=[^>]*\bdata-tooltip[\s>])[^>]*><b>x<\/b><\/span>$/,
'ui_tip uses the theme tooltip attributes of ui_help');
unlike($tip, qr/data-ui-tip|tabindex/, 'ui_tip needs no JavaScript attributes or extra tab stop');
unlike($tip, qr/data-ui-tip|tabindex/, 'ui_tip draws no tooltip of its own');
assert_no_handler_injection(
main::ui_dl([ { 'label' => $xss, 'value' => $xss, 'help' => $xss } ]),
'ui_dl hash row');
@@ -680,19 +680,6 @@ like(main::ui_form_columns_table('x.cgi', [ [ 'go', 'Go' ] ], 0, undef, undef,
'counter-free long lists retain filtering');
}
# Row titles remain literal text when converted to shared tooltip attributes.
{
my $title = q{Literal <tag> &amp;lt;example&amp;gt; "quoted".};
my $html = main::ui_multi_select_list('tip', [],
[ { value => 'one', label => 'One', attrs => { title => $title } } ]);
my ($tip) = $html =~ /<span (?=[^>]*\bui_tip\b)[^>]*aria-label="([^"]*)"/;
is(decode_attr($tip), q{Literal <tag> &lt;example&gt; "quoted".},
'CSS tooltip preserves angle brackets, entities, quotes and punctuation');
assert_no_handler_injection(main::ui_multi_select_list('tip_injection', [],
[ { value => 'one', label => 'One', attrs => { title => $xss } } ]),
'CSS row tooltip');
}
# Legacy attributes must retain row presentation and checkbox behavior.
{
my $tags = q{DISABLED="false" class="managed" style='font-style:italic' title="Policy &amp; owner" data-policy=managed};
@@ -707,14 +694,11 @@ like(main::ui_form_columns_table('x.cgi', [ [ 'go', 'Go' ] ], 0, undef, undef,
'legacy classes combine with the widget and disabled row classes');
like($html, qr/<div (?=[^>]*\bui_multi_item\b)(?=[^>]*style="font-style:italic")/,
'legacy styles apply to the whole row');
like($html, qr/<span (?=[^>]*\bui_tip\b)(?=[^>]*aria-label="Policy &amp; owner")/,
'legacy title entities are preserved in the shared CSS tooltip');
like($html, qr/<input (?=[^>]*value="locked")(?=[^>]*aria-description="Policy &amp; owner")/,
'checkbox descriptions remain available to assistive technology');
unlike($html, qr/\btitle=/, 'row descriptions do not create duplicate native tooltips');
like($html, qr/title="Policy &amp; owner"/,
'legacy title entities are preserved without double escaping');
like($html, qr/data-policy="managed"/,
'unquoted data attributes survive normalization');
unlike($html, qr/<input (?=[^>]*value="open")(?=[^>]*\sdisabled=)/,
unlike($html, qr/<input (?=[^>]*value="open")(?=[^>]*\bdisabled\b)/,
'disabled inside a quoted attribute does not disable the checkbox');
like($html, qr/data-note="a=b > c"/,
'quoted attribute values retain spaces, equals and greater-than signs');
@@ -735,7 +719,7 @@ like(main::ui_form_columns_table('x.cgi', [ [ 'go', 'Go' ] ], 0, undef, undef,
[ [ 'gone', 'Gone', q{disabled title='Retained'} ] ], [ ]);
like($missing, qr/<input (?=[^>]*value="gone")(?=[^>]*\bchecked\b)(?=[^>]*\bdisabled\b)/,
'missing selected values retain their legacy disabled attribute');
like($missing, qr/aria-label="Retained"/, 'missing values retain row attributes');
like($missing, qr/title="Retained"/, 'missing values retain row attributes');
my $fixed = main::ui_multi_select_list('fixed', [ ],
[ [ 'a', 'A', q{checked name=wrong value=wrong type=radio} ] ]);
like($fixed, qr/<input (?=[^>]*type='checkbox')(?=[^>]*name="fixed_item")(?=[^>]*value="a")/,
@@ -859,8 +843,8 @@ like(main::ui_form_columns_table('x.cgi', [ [ 'go', 'Go' ] ], 0, undef, undef,
'selected attributes take precedence over available option attributes');
like($html, qr/<input (?=[^>]*value="c")(?=[^>]*\bdisabled\b)/,
'unselected entries keep their option attributes');
like($html, qr/aria-label="Selected"/, 'legacy selected row keeps its tooltip');
like($html, qr/aria-label="Retained"/, 'missing legacy selection keeps its tooltip');
like($html, qr/title="Selected"/, 'legacy selected row keeps its tooltip');
like($html, qr/title="Retained"/, 'missing legacy selection keeps its tooltip');
is_deeply(\@values, [ [ 'b', 'B (selected)', q{disabled title="Selected"} ],
[ 'a', 'R&amp;D' ], [ 'gone', 'Missing', q{title="Retained"} ] ],
'legacy selected entries are not modified');

View File

@@ -95,7 +95,7 @@ try {
assert.equal(await evaluate("value('domains')"), 'parent\nchild');
assert.equal(await evaluate("box('domains').querySelector('[data-ui-multi-level]').hidden"), true);
assert.equal(await evaluate("box('domains').querySelector('.ui_multi_count').textContent"), '1 selected');
assert.equal(await evaluate("box('domains').querySelector('.ui_chip').textContent.trim()"), 'Disabled');
assert.equal(await evaluate("box('domains').querySelector('.ui_chip').textContent"), 'Disabled');
await evaluate("box('domains').querySelector('[data-ui-multi-action=children]').click()");
assert.equal(await evaluate("box('domains').querySelector('[data-ui-multi-level]').hidden"), false);
await evaluate(`const mode = box('domains').querySelector('select'); mode.value = '1'; mode.dispatchEvent(new Event('change', {bubbles:true}));`);
@@ -123,40 +123,6 @@ try {
assert.equal(await evaluate("value('grouped')"), 'image-0');
assert.equal(await evaluate("box('domains').querySelector('[data-ui-multi-level]').hidden"), true);
// Row descriptions use CSS on keyboard focus, without native or JS tips.
await evaluate(`box('grouped').scrollIntoView();
window.tip = box('grouped').querySelector('.ui_tip');
tip.querySelector('input').focus();`);
assert.equal(await evaluate("getComputedStyle(tip, '::before').display"), 'block');
assert.equal(await evaluate("tip.getAttribute('aria-label')"), 'Description for image 0. Literal <text> & punctuation.');
assert.equal(await evaluate("tip.querySelector('input').getAttribute('aria-description')"),
await evaluate("tip.getAttribute('aria-label')"));
assert.equal(await evaluate("box('grouped').querySelectorAll('.ui_multi_item[title], .tooltip').length"), 0);
// The arrow follows the checkbox center, not a fixed label offset.
const arrowOffset = await evaluate(`{ const a = getComputedStyle(tip, '::after');
const r = tip.querySelector('input').getBoundingClientRect();
parseFloat(a.left) + parseFloat(a.width) / 2 - (r.x + r.width / 2); }`);
assert.ok(Math.abs(arrowOffset) < 1, 'tooltip arrow points at the checkbox');
const nextTip = await evaluate(`{ const r = box('grouped').querySelectorAll('.ui_tip')[1].getBoundingClientRect();
({x: r.x + 5, y: r.y + r.height / 2}); }`);
await page('Input.dispatchMouseEvent', { type: 'mouseMoved', ...nextTip });
assert.equal(await evaluate("getComputedStyle(tip, '::before').display"), 'none', 'hover takes priority over old focus');
assert.equal(await evaluate("getComputedStyle(box('grouped').querySelectorAll('.ui_tip')[1], '::before').display"), 'block');
await page('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 1100, y: 0 });
const tipColors = [];
for (const scheme of ['light', 'dark']) {
await evaluate(`box('grouped').dataset.uiScheme = '${scheme}'`);
tipColors.push(await evaluate("getComputedStyle(tip, '::before').backgroundImage"));
}
assert.notEqual(tipColors[0], tipColors[1], 'tooltip colors follow the widget scheme');
await page('Emulation.setDeviceMetricsOverride', { width: 375, height: 900, deviceScaleFactor: 1, mobile: false });
const tipBounds = await evaluate(`{ const s = getComputedStyle(tip, '::before');
({left: parseFloat(s.left), right: parseFloat(s.left) + parseFloat(s.width)}); }`);
assert.ok(tipBounds.left >= 0 && tipBounds.right <= 375, 'tooltip fits the narrow viewport');
await evaluate("tip.querySelector('input').blur()");
assert.equal(await evaluate("getComputedStyle(tip, '::before').display"), 'none');
await page('Emulation.setDeviceMetricsOverride', { width: 1120, height: 900, deviceScaleFactor: 1, mobile: false });
// Inspect real Cloudmin metadata in both schemes and a narrow viewport.
if (process.argv[3]) {
await evaluate("document.querySelector('#checks').hidden = true");

View File

@@ -22,7 +22,6 @@ my @options = map { { value => "image-$_", label => "Image $_",
group => { label => $_ < 4 ? 'Recommended' : $_ < 8 ? 'Supported' : 'Older',
state => $_ < 4 ? 'success' : $_ < 8 ? 'neutral' : 'warning' },
disabled => $_ == 9,
attrs => { title => "Description for image $_. Literal <text> & punctuation." },
metadata => [ { label => '10 GiB', title => 'Minimum disk size: 10 GiB', icon => 'hard-drive' },
{ label => $_ < 8 ? 'Support until 2030' : 'Support ended 2025',
icon => 'clock', state => $_ < 8 ? 'neutral' : 'warning' } ] }

View File

@@ -173,20 +173,6 @@ subtest 'Data::Dumper format' => sub {
require Data::Dumper;
require JSON::PP;
# Newer peers emit native booleans as !!1 and !!0, including RPC results.
for my $value (0, 1) {
is(main::unserialise_variable("\$VAR1 = !!$value;"), !!$value,
"literal !!$value retains Perl boolean semantics");
is_deeply(main::unserialise_variable(
"\$VAR1 = {'rv' => !!$value, 'status' => 1};"),
{ 'rv' => !!$value, 'status' => 1 },
"fast RPC response accepts !!$value");
}
is_deeply(main::unserialise_variable(q{$VAR1 = [!!1, !!0, \!!0];}),
[ !!1, !!0, \!!0 ], 'booleans work in arrays and scalar references');
is_deeply(main::unserialise_variable(q{$VAR1 = ['!!1', "!!0"];}),
[ '!!1', '!!0' ], 'quoted boolean text remains unchanged');
# Spot-check the supported scalar, reference, object and container forms.
my $str = 'x';
my $shared = [ 1 ];
@@ -196,7 +182,7 @@ subtest 'Data::Dumper format' => sub {
'sref' => \$str, 'rref' => \\'y', 'a' => $shared, 'b' => $shared,
'obj' => bless({ 'x' => 1 }, 'RPC::Untrusted'),
'sobj' => bless(\$str, 'RPC::Untrusted'),
'empty' => [], 'eh' => {}, 'true' => !!1, 'false' => !!0 };
'empty' => [], 'eh' => {} };
$v->{'self'} = $v;
# Data::Dumper cannot preserve a reference to a hash value if it sees that
# reference first. Sorting keys makes this test deterministic.
@@ -277,15 +263,6 @@ subtest 'Data::Dumper format' => sub {
# Reject hostile input without executing it.
our $ran = 0;
my @hostile = (
q{$VAR1 = !!2;},
q{$VAR1 = !!10;},
q{$VAR1 = !!01;},
q{$VAR1 = !!1e0;},
q{$VAR1 = !!0.0;},
q{$VAR1 = !!1foo;},
q{$VAR1 = !!($main::ran = 1);},
q{$VAR1 = !!0 || do { $main::ran = 1 };},
q{$VAR1 = !!1; $main::ran = 1;},
q{$VAR1 = {}; $main::ran = 1;},
q{$VAR1 = "@{[ $main::ran = 1 ]}";},
q{$VAR1 = "${\ ($main::ran = 1)}";},

View File

@@ -4530,10 +4530,9 @@ return &ui_tag('code', &html_escape($str), { 'class' => 'ui_code' });
=head2 ui_tip(content-html, tip)
Wraps HTML content in a CSS tooltip, shown on hover or focus within the
content. Load ui_page_assets when using this outside a widget page.
The tip is text; HTML tags are stripped. For a question-mark help bubble,
use ui_help instead.
Wraps arbitrary HTML content so the given plain-text tip is shown on
hover by the theme's tooltip, the same one ui_help uses. For a plain
question-mark help bubble, use ui_help instead.
=cut
sub ui_tip
@@ -6133,7 +6132,7 @@ Size, add-if-missing, titles and width are ignored.
=item values - Array reference of selected scalars or [ value, label, attributes ] entries. Attributes are also retained when a selected entry is missing from options.
=item options - Array reference of [ value, label, attributes ] entries or hashes with keys value, label, suffix (muted text after the label), level (indentation depth), tag (chip at the right), disabled and attrs. Attributes may be a hash or trusted HTML attribute string, as in ui_select. disabled applies to the checkbox; title becomes a CSS tooltip around the checkbox label; style and class apply to the row. Widget identity and selection remain controlled by the library.
=item options - Array reference of [ value, label, attributes ] entries or hashes with keys value, label, suffix (muted text after the label), level (indentation depth), tag (chip at the right), disabled and attrs. Attributes may be a hash or trusted HTML attribute string, as in ui_select. disabled applies to the checkbox; other attributes, such as title, style and class, apply to the row. Widget identity and selection remain controlled by the library.
=item badges - An option hash may also contain a badges array. Each entry holds ui_badge arguments: [ label, state, options ]. Badges appear separately at the right, beside any tag. Their labels are escaped and included in filtering.
@@ -6403,16 +6402,10 @@ foreach my $it (@items) {
# Checkbox renderers may place HTML outside their label element.
my $ctags = "data-ui-multi-item='1'";
$ctags .= ' aria-label="'.$filter_label.'"' if ($opts->{'html'});
# Reuse the CSS tooltip for row titles, without a second native tooltip.
my $tip = delete($it->{'attrs'}->{'title'});
$ctags .= ' aria-description="'.&html_escape($tip, 1).'"'
if (defined($tip) && $tip ne '');
my $row = &ui_checkbox($inputname.'_item', $val, $label,
$selected{$val} ? 1 : 0,
$ctags,
$dis || $it->{'disabled'} ? 1 : 0);
$row = &ui_tip($row, &html_escape($tip, 1))
if (defined($tip) && $tip ne '');
if ($note && $it->{'kids'}) {
my $nattrs = { 'class' => 'ui_multi_note' };
$nattrs->{'hidden'} = undef if (!$folded);

View File

@@ -124,144 +124,6 @@
text-align: left;
}
/* Shared CSS tooltips. Theme tokens keep the bubble and its arrow readable
* in light and dark palettes. Ordinary help bubbles retain their theme style. */
.ui_tip[data-tooltip][aria-label] {
position: relative;
display: inline-block;
vertical-align: baseline;
--ui-tip-bg: var(--ui-fg, var(--text-color, #212121));
--ui-tip-fg: var(--ui-surface, var(--main-content, var(--bg-color, #fff)));
}
.ui_tip[data-tooltip][aria-label]::before,
.ui_tip[data-tooltip][aria-label]::after {
position: absolute;
pointer-events: none;
display: none;
z-index: 1060;
}
.ui_tip[data-tooltip][aria-label]::before {
content: attr(aria-label);
bottom: calc(100% + 8px);
left: 0;
width: max-content;
box-sizing: border-box;
max-width: min(32rem, calc(100vw - 24px));
padding: 7px 10px;
border-radius: 5px;
/* Theme text colors may have alpha; paint over a solid surface. */
background: linear-gradient(var(--ui-tip-bg), var(--ui-tip-bg)),
var(--ui-tip-fg);
color: var(--ui-tip-fg);
font-size: 13px;
line-height: 1.45;
font-weight: normal;
font-style: normal;
text-align: left;
text-transform: none;
text-shadow: none;
white-space: normal;
overflow-wrap: anywhere;
}
.ui_tip[data-tooltip][aria-label]::after {
content: '';
bottom: calc(100% + 4px);
left: 12px;
width: 8px;
height: 8px;
background: linear-gradient(var(--ui-tip-bg), var(--ui-tip-bg)),
var(--ui-tip-fg);
transform: rotate(45deg);
}
.ui_tip[data-tooltip][aria-label]:is(:hover, :focus-within)::before,
.ui_tip[data-tooltip][aria-label]:is(:hover, :focus-within)::after {
display: block;
}
/* A hovered row takes priority over the checkbox that still has focus. */
.ui_multi:has(.ui_tip:hover) .ui_tip:not(:hover)::before,
.ui_multi:has(.ui_tip:hover) .ui_tip:not(:hover)::after {
display: none;
}
/* Fixed anchors escape scrolling lists. Scope each pair so repeated entries
* cannot attach to another row's tooltip. Older browsers use the absolute
* positioning above. */
@supports (anchor-scope: --ui-tip) {
.ui_tip[data-tooltip][aria-label] {
anchor-name: --ui-tip;
anchor-scope: --ui-tip, --ui-tip-box, --ui-tip-input;
}
.ui_tip[data-tooltip][aria-label]::before {
position: fixed;
position-anchor: --ui-tip;
anchor-name: --ui-tip-box;
bottom: anchor(top);
left: anchor(left);
margin-bottom: 8px;
position-try-fallbacks: --ui-tip-right, --ui-tip-fit,
--ui-tip-below, --ui-tip-below-right, --ui-tip-below-fit;
position-visibility: anchors-visible;
}
.ui_tip[data-tooltip][aria-label]::after {
position: fixed;
position-anchor: --ui-tip;
bottom: auto;
/* Attach the arrow to the nearest bubble edge after a position change. */
top: calc(clamp(anchor(--ui-tip-box top), anchor(bottom),
anchor(--ui-tip-box bottom)) - 4px);
left: clamp(calc(anchor(--ui-tip-box left) + 8px),
calc(anchor(left) + 12px), calc(anchor(--ui-tip-box right) - 16px));
position-visibility: anchors-visible;
}
/* Point list descriptions at the checkbox, whose size varies by theme.
* Leave room beside it for the bubble's rounded corner. */
.ui_multi .ui_tip input[type="checkbox"] {
anchor-name: --ui-tip-input;
}
.ui_multi .ui_tip[data-tooltip][aria-label]::before {
left: calc(anchor(--ui-tip-input center) - 12px);
}
.ui_multi .ui_tip[data-tooltip][aria-label]::after {
left: clamp(calc(anchor(--ui-tip-box left) + 8px),
calc(anchor(--ui-tip-input center) - 4px),
calc(anchor(--ui-tip-box right) - 16px));
}
@position-try --ui-tip-right {
left: auto;
right: anchor(right);
}
/* A long description can need more room than either side of its label. */
@position-try --ui-tip-fit {
left: 12px;
right: 12px;
width: auto;
}
@position-try --ui-tip-below {
bottom: auto;
top: anchor(bottom);
margin-top: 8px;
margin-bottom: 0;
}
@position-try --ui-tip-below-right {
bottom: auto;
top: anchor(bottom);
left: auto;
right: anchor(right);
margin-top: 8px;
margin-bottom: 0;
}
@position-try --ui-tip-below-fit {
bottom: auto;
top: anchor(bottom);
left: 12px;
right: 12px;
width: auto;
margin-top: 8px;
margin-bottom: 0;
}
}
.ui_multi .ui_tip { display: inline-flex; align-items: center; min-width: 0; }
.ui_multi .ui_tip > input[type="checkbox"] { margin-right: 6px; }
/* Built-in dark palette, enabled with data-ui-scheme="dark" on any
* ancestor element (or on the .ui_page wrapper itself, via the scheme
* option of ui_page_start). With data-ui-scheme="auto" the dark
@@ -419,9 +281,7 @@ body:has(.ui_page[data-ui-scheme="dark"]) {
.ui_field, .ui_field * {
box-sizing: border-box;
}
/* Reset widget paragraphs without removing spacing from help and other prose. */
.ui_page h1, .ui_page h2, .ui_page p.ui--p,
.ui_page dl, .ui_page dd { margin: 0; }
.ui_page h1, .ui_page h2, .ui_page p, .ui_page dl, .ui_page dd { margin: 0; }
.ui_page svg.ui_svg_icon { flex-shrink: 0; vertical-align: -0.18em; }
/* Underline plain text links, but keep links styled as buttons undecorated. */

View File

@@ -56,10 +56,6 @@ if ($$s =~ /\G'/gc) {
elsif ($$s =~ /\G"/gc) {
return _parse_double($s);
}
elsif ($$s =~ /\G!!([01])(?![A-Za-z0-9_.])/gc) {
# Read native boolean literals without evaluating arbitrary expressions.
return $1 eq '1';
}
elsif ($$s =~ /\G(-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][-+]?[0-9]+)?)(?![A-Za-z0-9_.])/gc) {
# Return an unquoted number as a numeric scalar.
return $1 + 0;