Files
small-packages/luci-theme-footstrap/docs/gallery.html
T
2026-09-20 01:22:33 +08:00

1195 lines
68 KiB
HTML

<!DOCTYPE html>
<!--
footstrap component gallery.
Every widget LuCI (or any luci-app-* package) can put on a page, rendered with
the real class names ui.js/cbi.js emit, so the theme can be checked without
hunting for a router page that happens to use the widget.
Serve it next to the stylesheet and open it without logging in:
scp docs/gallery.html router:/www/luci-static/footstrap/
open http://<router>/luci-static/footstrap/gallery.html
Toggle dark/light with the button; ?palette=rvht picks the wallpaper palette.
-->
<html lang="en" data-darkmode="true" data-palette="footstrap">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>footstrap — component gallery</title>
<link rel="stylesheet" href="cascade.css">
<style>
/* gallery chrome only — never part of the theme */
body { background: var(--fs-bg); color: var(--fs-text); }
/* `min-width: 0` because the theme lays <body> out as a FLEX container, and a flex item's
automatic minimum size is its min-content — so one deliberately unbreakable fixture (the 130-
character token below) stretched this wrapper to its max-width and pushed the whole page
sideways at every viewport. Measured at 390px: body 390, wrapper 1180, document scroll 790.
`width: 100%` is the other half: the item is sized shrink-to-fit on the cross axis, so
`min-width: 0` alone permits shrinking that nothing then asks for. */
.g-wrap { width: 100%; max-width: 1180px; min-width: 0; margin: 0 auto; padding: 24px 28px 80px; }
.g-bar { position: sticky; top: 0; z-index: 900; display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
padding: 12px 0; margin-bottom: 8px; background: var(--fs-bg); border-bottom: 1px solid var(--fs-border); }
.g-bar h1 { font-size: 18px; margin: 0; flex: 1 1 auto; }
.g-sec { margin: 34px 0 0; }
.g-sec > h2 { font-size: 13px; letter-spacing: .08em; text-transform: uppercase;
color: var(--fs-dim); margin: 0 0 12px; font-weight: 700; }
.g-row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 12px; }
.g-note { color: var(--fs-dim); font-size: 12px; margin: -4px 0 10px; }
/* The pathological fixtures are built to NOT fit — that is what they are for. On a live page the
fitter cards or scrolls them; the gallery has no LuCI JS, so they get a scroll frame here and
the page itself stays measurable. The frame is gallery chrome, never theme. */
.g-overwide { overflow-x: auto; overscroll-behavior-x: contain; }
/* the dialog is rendered INLINE here (position: static) so it can be looked at; out of its fixed
overlay it has no viewport to be centred in, so cap it to the column it now lives in */
/* the theme's own container context: on a page it is `#view` and `.cbi-map` that carry
`container-name: fs-content`, and the config table's card threshold is a @container query
against it. Without one the gallery renders that table in a context no page has. */
.g-wrap { container-type: inline-size; container-name: fs-view fs-content; }
#modal_overlay { max-width: 100%; }
#modal_overlay .modal { max-width: 100%; }
</style>
</head>
<body class="lang_en">
<div class="g-wrap">
<div class="g-bar">
<h1>footstrap — component gallery</h1>
<button class="cbi-button" onclick="var r=document.documentElement;r.setAttribute('data-darkmode',r.getAttribute('data-darkmode')==='true'?'false':'true')">Toggle dark</button>
<button class="cbi-button" onclick="var r=document.documentElement;r.setAttribute('data-palette',r.getAttribute('data-palette')==='rvht'?'footstrap':'rvht')">Toggle palette</button>
</div>
<div class="g-sec">
<h2>Typography &amp; text elements</h2>
<div class="cbi-section">
<h1>Heading 1</h1><h2>Heading 2</h2><h3>Heading 3</h3>
<h4>Heading 4</h4><h5>Heading 5</h5><h6>Heading 6</h6>
<p>Body paragraph with a <a href="#">link</a>, <strong>strong</strong>, <em>emphasis</em>,
<code>inline code</code>, <var>a var</var>, <small>small print</small>,
H<sub>2</sub>O and x<sup>2</sup>, an <abbr title="abbreviation">abbr</abbr>,
<kbd>Ctrl</kbd>+<kbd>C</kbd>, <del>deleted</del> and <ins>inserted</ins> text.</p>
<blockquote><p>A blockquote, as an app might render a hint.</p></blockquote>
<pre>#!/bin/sh
uci set network.lan.ipaddr='192.168.1.1'
uci commit network</pre>
<hr>
<ul><li>Unordered item</li><li>Another item</li></ul>
<ol><li>Ordered item</li><li>Another item</li></ol>
<ul class="unstyled"><li>Unstyled list item</li></ul>
<dl><dt>Term</dt><dd>Definition</dd><dt>Другой термин</dt><dd>Определение</dd></dl>
<address>OpenWrt, somewhere on a LAN</address>
</div>
</div>
<div class="g-sec">
<h2>Buttons — every cbi-button variant</h2>
<div class="g-row">
<button class="cbi-button">cbi-button</button>
<button class="cbi-button cbi-button-action">action</button>
<button class="cbi-button cbi-button-apply">apply</button>
<button class="cbi-button cbi-button-save">save</button>
<button class="cbi-button cbi-button-positive">positive</button>
<button class="cbi-button cbi-button-negative">negative</button>
<button class="cbi-button cbi-button-remove">remove</button>
<button class="cbi-button cbi-button-reset">reset</button>
<button class="cbi-button cbi-button-neutral">neutral</button>
<button class="cbi-button cbi-button-edit">edit</button>
<button class="cbi-button cbi-button-add">add</button>
<button class="cbi-button cbi-button-link">link</button>
<button class="cbi-button cbi-button-download">download</button>
<button class="cbi-button cbi-button-find">find</button>
<button class="cbi-button cbi-button-reload">reload</button>
<button class="cbi-button cbi-button-up">up</button>
<button class="cbi-button cbi-button-down">down</button>
<button class="cbi-button" disabled>disabled</button>
<button class="cbi-button spinning">spinning</button>
</div>
<div class="g-row">
<a class="btn" href="#">.btn</a>
<a class="btn primary" href="#">.btn.primary</a>
<!-- native input[type=button/submit/reset]: a REAL caption, not the markup that produces the
control — the old `value="input[type=button]"` was gallery documentation wearing the
control's own face, and pseudo-loc grew that 19-character syntax (not a real button's
caption) into a 28-character one, 47-64.5px past this row's flex parent at 320px/Large.
No real page ever emits this string as a button's label; a real one says "Submit". -->
<input type="button" value="Button">
<input type="submit" value="Submit">
<input type="reset" value="Reset">
</div>
<div class="g-row">
<a class="btn danger" href="#">.btn.danger</a>
<a class="btn error" href="#">.btn.error</a>
<a class="btn success" href="#">.btn.success</a>
<a class="btn info" href="#">.btn.info</a>
<a class="btn large" href="#">.btn.large</a>
<a class="btn small" href="#">.btn.small</a>
</div>
<p class="g-note">The colour words (<code>.danger/.error/.success/.info</code>) and size words
(<code>.large/.small</code>) come from <code>styles/base</code>, but the theme's plain
<code>.btn</code> rule sits in a LATER layer and overrides background/padding/radius —
so the theme has to re-state them (<code>theme/55-buttons.css</code>). They rendered as
neutral grey buttons, and <code>.btn.large</code> came out with small-button padding,
until it did. Nothing in stock LuCI emits them; a third-party <code>luci-app-*</code> can.</p>
<p class="g-note">Keyboard focus: <kbd>Tab</kbd> through this section. Every button must show a
ring — there was none at all before (base listed <code>button:hover</code>, not
<code>:focus</code>, and the theme's <code>box-shadow: none</code> erased the rest).</p>
<p class="g-note">Row-action size (inside a <code>.td</code>) is shown in the tables section.</p>
</div>
<div class="g-sec">
<h2>Page action bar</h2>
<!-- The Save &amp; Apply split control, copied from what ui.js ACTUALLY emits (captured off a
live 25.12 router on admin/network/firewall). The classes are on the WRAPPER — it is a
ui.Dropdown wearing `btn cbi-button cbi-button-apply` — and the label is an <li> inside its
own <ul>, not a nested <button>. The shape here used to be `<div class="cbi-dropdown">` with
an inner button, which is not a shape LuCI produces: the wrapper then matched the theme's
`.cbi-dropdown:not(.btn):not(.cbi-button)` field rule and rendered as a grey box glued to a
blue button. Anyone reading this card diagnosed a bug the theme does not have. -->
<div class="cbi-page-actions">
<div class="cbi-dropdown btn cbi-button cbi-button-apply important" tabindex="0" data-idref="g-apply">
<ul tabindex="-1">
<li data-value="0" selected display="0">Save &amp; Apply</li>
<li data-value="1">Apply unchecked</li>
</ul>
<span class="more" tabindex="-1">···</span>
<span class="open" tabindex="-1">▾</span>
<div><input type="hidden" value="0"></div>
</div>
<button class="cbi-button cbi-button-save">Save</button>
<button class="cbi-button cbi-button-reset">Reset</button>
</div>
<!-- The SAME control after the user picks the second mode. The class on the wrapper is the one
ui.js chose for the selected value (luci.js's ComboButton `classes` map: 0 -> cbi-button-apply,
1 -> cbi-button-negative), so this is not a variant of the card above but its other half —
and it is the half that was invisible: a blanket `.cbi-page-actions .cbi-dropdown` rule
erased the negative fill and left --fs-on-danger on the page behind it, 1.13:1 in dark. -->
<div class="cbi-page-actions">
<div class="cbi-dropdown btn cbi-button cbi-button-negative important" tabindex="0" data-idref="g-apply-unchecked">
<ul tabindex="-1">
<li data-value="0">Save &amp; Apply</li>
<li data-value="1" selected display="0">Apply unchecked</li>
</ul>
<span class="more" tabindex="-1">···</span>
<span class="open" tabindex="-1">▾</span>
<div><input type="hidden" value="1"></div>
</div>
<button class="cbi-button cbi-button-save">Save</button>
<button class="cbi-button cbi-button-reset">Reset</button>
</div>
</div>
<div class="g-sec">
<h2>Form fields (.cbi-value rows)</h2>
<div class="cbi-section">
<h3>Section title</h3>
<div class="cbi-section-descr">A section description, as CBI renders it.</div>
<div class="cbi-section-node">
<div class="cbi-value">
<label class="cbi-value-title" for="g-text">Text input</label>
<div class="cbi-value-field"><input id="g-text" type="text" class="cbi-input-text" value="OpenWrt"></div>
</div>
<!-- task 0147 (second RU sweep), finding 1: under 767px theme/90-responsive.css restates
`flex: 1 1 100%` on the label (stacked above the field), and a flex item's automatic
minimum size is content-based once that basis is not smaller than the content — a
label whose text has too few break points renders at its own min-content instead of
the row's width. Sweep-measured on ssclash's settings form at 320px: 320px wide in a
254-276px row, the section clipped 33-50px with no scroller. The run below has no
space for the middle third on purpose, the shape that broke it; a normal RU sentence
with ordinary word breaks does not need this fixture to pass. -->
<div class="cbi-value">
<label class="cbi-value-title" for="g-longlabel">Автоматическоепереподключениексерверу при обрыве</label>
<div class="cbi-value-field"><input id="g-longlabel" type="text" class="cbi-input-text" value=""></div>
</div>
<div class="cbi-value">
<label class="cbi-value-title" for="g-pass">Password</label>
<div class="cbi-value-field"><input id="g-pass" type="password" class="cbi-input-password" value="secret"></div>
</div>
<div class="cbi-value">
<label class="cbi-value-title" for="g-inv">Invalid input</label>
<div class="cbi-value-field">
<input id="g-inv" type="text" class="cbi-input-text cbi-input-invalid" value="not an ip">
<div class="cbi-value-description">Expecting: valid IP address</div>
</div>
</div>
<div class="cbi-value">
<label class="cbi-value-title" for="g-sel">Native select</label>
<div class="cbi-value-field">
<select id="g-sel" class="cbi-input-select"><option>alpha</option><option>beta</option></select>
</div>
</div>
<div class="cbi-value">
<label class="cbi-value-title" for="g-msel">Multiple select</label>
<div class="cbi-value-field">
<select id="g-msel" multiple size="3"><option selected>one</option><option>two</option><option>three</option></select>
</div>
</div>
<!-- task 0145: a caption-less `div.cbi-checkbox` never gives the pill a sibling flex item
to shrink against, so this fixture stayed green through the whole life of the
pill-shrink/knob-overhang bug — every gallery gate, computed-diff included, measured
a switch that could never fail this way. `label.cbi-checkbox > input + label[for] +
TEXT` is package-manager.js's and banip's real markup (a raw checkbox with an inline
caption, not the ui.Checkbox widget), and the caption is the RU string the bug was
measured against: 'Автоматически удалять неиспользуемые зависимости', long enough to
shrink the pill at default density on every phone width — EN only broke it at
Density=Large. -->
<div class="cbi-value">
<label class="cbi-value-title" for="cb1">Checkbox (toggle)</label>
<div class="cbi-value-field">
<label class="cbi-checkbox"><input id="cb1" type="checkbox" checked><label for="cb1"></label> Автоматически удалять неиспользуемые зависимости</label>
</div>
</div>
<!-- ui.Checkbox with `tooltip` set (form.Flag.tooltip, e.g. luci-app-firewall's zones.js
`masq` — "IPv4 Masquerading"), markup exactly as ui.js's Checkbox.render() emits it:
the switch label immediately after the input, THEN a second label.cbi-tooltip-container
holding the icon glyph and its .cbi-tooltip popup. Two states, since the bug this covers
(a dead second switch pill painted where the icon belongs) showed on both. -->
<div class="cbi-value">
<label class="cbi-value-title" for="cb-tt-on">Checkbox + tooltip (checked)</label>
<div class="cbi-value-field">
<div class="cbi-checkbox"><input id="cb-tt-on" type="checkbox" checked><label for="cb-tt-on"></label><label class="cbi-tooltip-container">⚠️<div class="cbi-tooltip">Limited masquerading enabled</div></label></div>
</div>
</div>
<div class="cbi-value">
<label class="cbi-value-title" for="cb-tt-off">Checkbox + tooltip (unchecked)</label>
<div class="cbi-value-field">
<div class="cbi-checkbox"><input id="cb-tt-off" type="checkbox"><label for="cb-tt-off"></label><label class="cbi-tooltip-container">⚠️<div class="cbi-tooltip">Limited masquerading enabled</div></label></div>
</div>
</div>
<!-- task cbi-checkbox: forum.openwrt.org/t/251930 post #141 — luci-app-modemdata injects an
unlayered `label[for]{display:none!important}` scoped to its own markup, expecting the
switch to live on the native input (as it does under bootstrap). Reproduced verbatim,
scoped to this fixture only, to prove the switch stays visible from the INPUT alone. -->
<style>
.fs-fixture-hidden-label label[for] { display: none !important; }
</style>
<div class="cbi-value fs-fixture-hidden-label">
<label class="cbi-value-title" for="cb-hl-on">Checkbox, label[for] hidden (checked)</label>
<div class="cbi-value-field">
<label class="cbi-checkbox"><input id="cb-hl-on" type="checkbox" checked><label for="cb-hl-on"></label> Hide data</label>
</div>
</div>
<div class="cbi-value fs-fixture-hidden-label">
<label class="cbi-value-title" for="cb-hl-off">Checkbox, label[for] hidden (unchecked)</label>
<div class="cbi-value-field">
<label class="cbi-checkbox"><input id="cb-hl-off" type="checkbox"><label for="cb-hl-off"></label> Hide data</label>
</div>
</div>
<div class="cbi-value">
<label class="cbi-value-title">Radio</label>
<div class="cbi-value-field">
<label><input type="radio" name="r" checked> yes</label>
<label><input type="radio" name="r"> no</label>
</div>
</div>
<div class="cbi-value">
<label class="cbi-value-title" for="g-ta">Textarea</label>
<div class="cbi-value-field"><textarea id="g-ta" class="cbi-input-textarea" rows="3">option one
option two</textarea></div>
</div>
<div class="cbi-value" style="margin-top:28px">
<label class="cbi-value-title" for="g-ta2">Textarea (scrolling — NFQWS_OPT-style config)</label>
<div class="cbi-value-field"><textarea id="g-ta2" class="cbi-input-textarea" rows="6" style="height:150px">#Yv11
--filter-tcp=443
--hostlist=/opt/zapret/ipset/zapret-hosts.txt
--dpi-desync=fake,multisplit
--dpi-desync-fake-tls=0x00000000
--dpi-desync-fake-tls=!
--dpi-desync-split-pos=1,sniext+1,host+1,midsld
--dpi-desync-ttl=0
--new
--filter-udp=443
--dpi-desync=fake
--dpi-desync-repeats=6</textarea></div>
</div>
<div class="cbi-value">
<label class="cbi-value-title" for="g-file">File upload (bare input)</label>
<div class="cbi-value-field"><input id="g-file" type="file"></div>
</div>
<!-- ui.FileUpload, CLOSED — what a form shows before you click it. The browser below is
the same widget with .open on it; LuCI only ever has one of the two visible. -->
<div class="cbi-value">
<label class="cbi-value-title">File upload (ui.FileUpload, closed)</label>
<div class="cbi-value-field">
<div>
<button class="btn open-file-browser">Select file…</button>
<div class="cbi-filebrowser"></div>
<input type="hidden" name="g-fb0" value="">
</div>
</div>
</div>
<!-- ui.FileUpload, OPEN. Markup copied from ui.js (renderListing + renderUpload), classes
and nesting included: the listing rows are <li> with .name / .mtime.hide-xs / a button
div, and the upload strip is [Browse…] [filename input] [Upload file] in one .upload
flex row. Both are only reachable after two clicks on a live router, which is why the
row that could not hold its button went unseen. -->
<div class="cbi-value">
<label class="cbi-value-title">File upload (ui.FileUpload, browser open)</label>
<div class="cbi-value-field">
<div>
<button class="btn open-file-browser" style="display:none">Select file…</button>
<div class="cbi-filebrowser open">
<p><a href="#">/etc/luci-uploads</a> » <a href="#">certs</a></p>
<ul>
<li>
<div class="name"><img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M1 3h5l1 2h8v9H1z' fill='%23888'/%3E%3C/svg%3E" width="16" class="middle" alt=""> <a href="#">subdir</a></div>
<div class="mtime hide-xs"> 2026-07-14 18:31:07 </div>
<div><button class="btn cbi-button-negative">Delete</button></div>
</li>
<li>
<div class="name"><img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3 1h7l3 3v11H3z' fill='%23888'/%3E%3C/svg%3E" width="16" class="middle" alt=""> <a href="#" style="font-weight:bold">fullchain.pem</a></div>
<div class="mtime hide-xs"> 2026-07-14 18:29:55 </div>
<div><button class="btn">Deselect</button><button class="btn">Download</button><button class="btn cbi-button-negative">Delete</button></div>
</li>
</ul>
<div class="right">
<div class="upload">
<input type="file" style="display:none">
<button class="btn">Browse…</button>
<div><input type="text" placeholder="Filename"></div>
<button class="btn cbi-button-save">Upload file</button>
</div>
<a href="#" class="btn cbi-button">Create</a>
<a href="#" class="btn">Cancel</a>
</div>
</div>
<input type="hidden" name="g-fb1" value="">
</div>
</div>
</div>
<div class="cbi-value">
<label class="cbi-value-title" for="g-range">Range slider</label>
<div class="cbi-value-field">
<div class="cbi-range-slider">
<input id="g-range" type="range" min="0" max="100" value="40">
<span class="cbi-range-slider-value">40</span>
</div>
</div>
</div>
<div class="cbi-value">
<label class="cbi-value-title">Dynamic list</label>
<div class="cbi-value-field">
<div class="cbi-dynlist">
<div class="item" tabindex="0"><span>192.168.1.1</span></div>
<div class="item" tabindex="0"><span>1.1.1.1</span></div>
<div class="add-item"><input type="text" class="cbi-input-text" placeholder="add entry…"></div>
</div>
</div>
</div>
<div class="cbi-value">
<label class="cbi-value-title">Buttons in a field</label>
<div class="cbi-value-field">
<button class="cbi-button cbi-button-neutral">Start</button>
<button class="cbi-button cbi-button-neutral">Restart</button>
<button class="cbi-button cbi-button-negative">Stop</button>
</div>
</div>
</div>
<div class="cbi-section-error"><ul><li>A section error, as CBI renders a validation failure.</li></ul></div>
</div>
</div>
<div class="g-sec">
<h2>cbi-dropdown widget (the JS select)</h2>
<div class="g-row">
<div class="cbi-dropdown" tabindex="0"><ul><li class="focus" display="0">Selected value</li></ul><span class="open">▾</span></div>
<div class="cbi-dropdown btn cbi-button cbi-button-action" tabindex="0"><ul><li>Button dropdown</li></ul><span class="open">▾</span></div>
<!-- The Save & Apply split control WHILE APPLYING: a .cbi-dropdown that is also a spinning
button. Its padding has to out-rank the dropdown box's own `padding: 0` — the one case
the spinner's !important used to exist for, and the one nothing rendered. -->
<div class="cbi-dropdown btn cbi-button cbi-button-apply spinning" tabindex="0"><ul><li>Applying…</li></ul><span class="open">▾</span></div>
<!-- Field dropdown (not .btn/.cbi-button) with a value longer than its 400px cap — the
https-dns-proxy/nlbw-config/interface-edit shape task 0149 measured cutting mid-glyph at
320px (RU: 132/225/280px past the box at Compact/Normal/Large). No `title`/`aria-label`:
the ellipsis is visual only, the full string stays in the DOM and the accessibility tree. -->
<div class="cbi-dropdown" tabindex="0"><ul><li class="focus" display="0">Автоматический выбор лучшего доступного вышестоящего DNS-сервера по задержке отклика</li></ul><span class="open">▾</span></div>
</div>
<p class="g-note">Closed controls only. The open list — and the create-item row that goes with it —
is in the Combobox section below.</p>
</div>
<div class="g-sec">
<h2>Combobox (cbi-dropdown + custom value)</h2>
<!--
ui.Combobox IS ui.Dropdown with `{ multiple: false, create: true, optional: true }` forced on
(ui.js), so its markup is the dropdown's plus one extra row: a bare `<li data-value="-">` whose
only child is `<input class="create-item-input">` — the field a user types a custom value into.
That row is the whole reason this section exists; it is the one dropdown state no shipped LuCI
page shows standing still, so nothing else in this gallery renders it.
Everything below is what ui.js leaves in the DOM, written out by hand because there is no JS
here to build it: bind() appends `<span class="more">` + `<span class="open">` + the trailing
`<div>` that holds the hidden inputs, prepends the `<li placeholder>` when the widget is
optional, and marks the shown item `selected` + `display="0"`.
-->
<div class="g-row">
<!-- closed, with a value chosen -->
<div class="cbi-dropdown" optional="" tabindex="0">
<ul tabindex="-1">
<li placeholder="">-- Please choose --</li>
<li data-value="wan">wan</li>
<li data-value="lan" selected="" display="0">lan</li>
<li data-value="wg0">wg0</li>
<li data-value="-"><input type="text" class="create-item-input" placeholder="-- custom --" inputmode="text" enterkeyhint="done"></li>
</ul>
<span class="more" tabindex="-1">···</span>
<span class="open" tabindex="-1">▾</span>
<div><input type="hidden" name="g-combo" value="lan"></div>
</div>
<!-- closed and unset: optional, so bind() puts `selected`/`display` on the placeholder li
(and saveValues writes no hidden input for it) -->
<div class="cbi-dropdown" optional="" tabindex="0">
<ul tabindex="-1">
<li placeholder="" selected="" display="0">-- Please choose --</li>
<li data-value="wan">wan</li>
<li data-value="lan">lan</li>
<li data-value="-"><input type="text" class="create-item-input" placeholder="-- custom --" inputmode="text" enterkeyhint="done"></li>
</ul>
<span class="more" tabindex="-1">···</span>
<span class="open" tabindex="-1">▾</span>
<div></div>
</div>
</div>
<!-- OPEN. openDropdown() sets [open], clones the <ul> into a `ul.preview` (which is what you
still see in the control's own box — every li but the `display`-marked one stays hidden, so
the clone's copy of the create-item input is present but not shown; that duplicate is real,
do not "fix" it), adds `.dropdown` to the original <ul>, hands each of ITS li a tabindex,
and writes top/max-height inline. The list is absolutely positioned, hence the bottom margin
on the row — gallery staging only, the same trick the Modal section uses. -->
<div class="g-row" style="margin-bottom:250px">
<div class="cbi-dropdown" optional="" open="" tabindex="0">
<ul class="dropdown" tabindex="-1" style="top:38px;max-height:280px">
<li placeholder="" tabindex="0">-- Please choose --</li>
<li data-value="wan" tabindex="0">wan</li>
<li data-value="lan" selected="" display="0" tabindex="0">lan</li>
<li data-value="wg0" tabindex="0">wg0</li>
<li data-value="-" tabindex="0"><input type="text" class="create-item-input" placeholder="-- custom --" inputmode="text" enterkeyhint="done"></li>
</ul>
<ul class="preview">
<li placeholder="">-- Please choose --</li>
<li data-value="wan">wan</li>
<li data-value="lan" selected="" display="0">lan</li>
<li data-value="wg0">wg0</li>
<li data-value="-"><input type="text" class="create-item-input" placeholder="-- custom --" inputmode="text" enterkeyhint="done"></li>
</ul>
<span class="more" tabindex="-1">···</span>
<span class="open" tabindex="-1">▾</span>
<div tabindex="0"><input type="hidden" name="g-combo-open" value="lan"></div>
</div>
</div>
<p class="g-note">What to look at: the create-item row must read as a text field, not as a choice —
it is the only row a user can type in. Base gives it <code>width: 100%</code> via
<code>li &gt; input.create-item-input:first-child:last-child</code>, which only matches because
the input is that li's sole child; the theme paints it as a normal input
(<code>theme/65-dropdown.css</code>). Check its focus ring, and check that the row's own hover
pill does not fight the field inside it.</p>
<p class="g-note">The theme also carries a rule for <code>li.create-item</code>, a class stock
LuCI does not emit anywhere (grepped: <code>ui.js</code> gives the li no class at all, only the
input gets <code>.create-item-input</code>). It is kept under the coverage contract — a
third-party app may still emit it — but it is deliberately not rendered here, because this
gallery shows the markup LuCI really produces.</p>
</div>
<div class="g-sec">
<h2>cbi-dropdown, MULTIPLE and open</h2>
<!-- The other half of the widget, and it is not just the single-select list with checkboxes: a
multi-select wraps every row's text in `<form><label>`, which is a second element for any
descendant rule in a field to hit (it is how the menu lost its sans face to the mono rule
in theme/60-inputs.css). The checkbox carries no `checked` attribute — ui.js paints the
state off `li[selected]` — so copy the shape exactly, including the empty <form>.
Absolutely positioned list, hence the row's bottom margin: gallery staging only. -->
<!-- Inside a .cbi-value-field on purpose: that is where a form's dropdown actually lives, and it
is the container whose descendant rule hands out the mono face. Staged bare, the menu never
meets the rule that used to take its font. -->
<div class="cbi-value" style="margin-bottom:200px">
<label class="cbi-value-title">Network bands</label>
<div class="cbi-value-field">
<div class="cbi-dropdown" multiple="" optional="" open="" tabindex="0">
<ul class="dropdown" tabindex="-1" style="top:38px;max-height:280px">
<li data-value="lan" selected="" display="0" tabindex="0" class="focus">
<form><input type="checkbox" tabindex="-1"></form><label>lan</label>
</li>
<li data-value="wan" tabindex="0">
<form><input type="checkbox" tabindex="-1"></form><label>wan</label>
</li>
<li data-value="wg0" selected="" display="1" tabindex="0">
<form><input type="checkbox" tabindex="-1"></form><label>wg0</label>
</li>
</ul>
<ul class="preview">
<li data-value="lan" selected="" display="0"><form><input type="checkbox" tabindex="-1"></form><label>lan</label></li>
<li data-value="wan"><form><input type="checkbox" tabindex="-1"></form><label>wan</label></li>
<li data-value="wg0" selected="" display="1"><form><input type="checkbox" tabindex="-1"></form><label>wg0</label></li>
</ul>
<span class="more" tabindex="-1">···</span>
<span class="open" tabindex="-1">▾</span>
<div tabindex="0"><input type="hidden" name="g-multi-open" value="lan"></div>
</div>
</div>
</div>
<p class="g-note">What to look at: every row is the menu's own face, not the mono a field hands
its descendants — the <code>&lt;label&gt;</code> is a LABEL, and mono makes a wordy option list
(<code>3G Band 1</code>) read stretched. A chosen row says so with its CHECKBOX, so it does not
also wear the accent rail the single-select row above needs: the rail is an inset shadow on a
rounded row, i.e. an arc, and a column of them reads as a broken frame. The focused row wears
the theme's ring, inset — the browser's own <code>outline: auto</code> is drawn outside the row
and the menu clips it.</p>
</div>
<div class="g-sec">
<h2>Tabs</h2>
<!-- TWO strips, TWO active markers, and the difference is not cosmetic: `ul.tabs` is the
THEME's own section strip (fs-chrome.js renderTabMenu) and marks the current page with
`.active` + `aria-current="page"`, on an `li.tabmenu-item-<name>`; `ul.cbi-tabmenu` is
ui.js's in-form strip and marks it with `.cbi-tab`. theme/40-tabs.css says so and styles
both. This card used to write `<li class="cbi-tab">` in BOTH, so the first strip rendered
with NO active tab at all (measured: all three li carried the muted ink) — a shape nothing
can emit, which is exactly what a card must not show. -->
<ul class="tabs">
<li class="tabmenu-item-general active"><a href="#" aria-current="page">General Settings</a></li>
<li class="tabmenu-item-advanced"><a href="#">Advanced</a></li>
<li class="tabmenu-item-errors cbi-tab-disabled" data-errors="1"><a href="#">With errors</a></li>
</ul>
<ul class="cbi-tabmenu">
<li class="cbi-tab"><a href="#">Mode A</a></li>
<li class="cbi-tab-disabled"><a href="#">Mode B</a></li>
<!-- adblock's Overview page, RU: a `.cbi-tabmenu > li` label that is a sentence, not a word.
Narrow the window and this is the one that used to escape its column (measured at
320px/Density Large: 293px in a 264px column, 28.6px past it, no scroller) — the `a`
already ellipsises (base/50-chrome.css) but the li's own `max-width: none` never let it
shrink that far. EN has no fixture this long because EN never broke. -->
<li class="cbi-tab"><a href="#">Настройки межсетевого экрана</a></li>
</ul>
</div>
<div class="g-sec">
<h2>Alerts &amp; notices</h2>
<div class="alert-message">Plain alert-message.</div>
<div class="alert-message notice">notice — a poll or a hint.</div>
<div class="alert-message info">info</div>
<div class="alert-message success">success</div>
<div class="alert-message warning">warning — configuration not committed.</div>
<div class="alert-message danger">danger — the interface will go down.</div>
<div class="alert-message error">error</div>
<div class="alert-message warning">
<h4>With a heading and actions</h4>
<p>Some explanatory body text.</p>
<div><button class="cbi-button">Stop showing</button> <button class="cbi-button">Dismiss</button></div>
</div>
<!-- The rest of what base styles inside an alert: h5, a list, and a <pre>. Only h4 and <p> were
here, so the audit's absorption backlog for `.alert-message li/ul/pre/h5` could not be
settled either way — an un-rendered widget shows no diff, which reads as "already dead". -->
<div class="alert-message danger">
<h5>Heading 5 inside an alert</h5>
<p>Body.</p>
<ul><li>a list item</li><li>another one</li></ul>
<pre>a &lt;pre&gt; block inside an alert</pre>
</div>
<!-- A table INSIDE an alert: the firmware Current/Available readout (admin/system/flash). The
theme flattens it so it does not read as a card on a card, and that rule had never been
rendered by anything here — a padding change to it measured 0 diffs because the shape was
missing. -->
<div class="alert-message warning">
<h4>Firmware update available</h4>
<table class="table">
<tr class="tr table-titles"><th class="th">Field</th><th class="th">Current</th><th class="th">Available</th></tr>
<tr class="tr"><td class="td">Version</td><td class="td">25.12.0</td><td class="td">25.12.1</td></tr>
<tr class="tr"><td class="td">Size</td><td class="td">7.8 MB</td><td class="td">7.9 MB</td></tr>
</table>
</div>
</div>
<div class="g-sec">
<h2>Tooltips (.cbi-tooltip — the colour words too)</h2>
<!-- ui.js pops these on a .cbi-tooltip-container; the colour variants are what a third-party
luci-app-* uses for a validation hint, and NOTHING else in this gallery renders them. -->
<div style="position:relative;height:190px">
<div class="cbi-tooltip" style="position:static;display:block;opacity:1;margin:0 0 6px">plain .cbi-tooltip</div>
<div class="cbi-tooltip info" style="position:static;display:block;opacity:1;margin:0 0 6px">.cbi-tooltip.info</div>
<div class="cbi-tooltip success" style="position:static;display:block;opacity:1;margin:0 0 6px">.cbi-tooltip.success</div>
<div class="cbi-tooltip error" style="position:static;display:block;opacity:1;margin:0 0 6px">.cbi-tooltip.error</div>
<div class="cbi-tooltip notice" style="position:static;display:block;opacity:1">.cbi-tooltip.notice</div>
</div>
</div>
<div class="g-sec">
<h2>.cbi-select (the non-JS select fallback)</h2>
<!-- What LuCI renders when fs-select.js has not (yet) turned a &lt;select&gt; into a ui.Dropdown,
and what a third-party package emits directly. base styles it as a field; the theme names
every input TYPE but never this class, so it was invisible to every check. -->
<div class="cbi-value">
<label class="cbi-value-title" for="g-cbisel">.cbi-select</label>
<div class="cbi-value-field">
<select id="g-cbisel" class="cbi-select"><option>one</option><option>two</option></select>
</div>
</div>
<div class="cbi-value">
<label class="cbi-value-title" for="g-cbisel-bad">.cbi-select.cbi-input-invalid</label>
<div class="cbi-value-field">
<select id="g-cbisel-bad" class="cbi-select cbi-input-invalid"><option>rejected value</option></select>
</div>
</div>
<!-- The other shape, and the one every check was blind to: luci-compat wraps the native select
in a DIV that carries the class, so `.cbi-select:not(select)` is a real element with a real
`select` child. Only this shape gets the shell paint (a replaced element cannot), which is
why the theme has a `:not(select)` rule at all — and with no example here, nothing measured
it. `[disabled]` is separate on purpose: base dims only the shell's ::before, so the state
is easy to lose. -->
<div class="cbi-value">
<label class="cbi-value-title" for="g-cbisel-shell">.cbi-select (the DIV shell)</label>
<div class="cbi-value-field">
<div class="cbi-select"><select id="g-cbisel-shell"><option>one</option><option>two</option></select></div>
</div>
</div>
<div class="cbi-value">
<label class="cbi-value-title" for="g-cbisel-off">.cbi-select[disabled] (the DIV shell)</label>
<div class="cbi-value-field">
<div class="cbi-select" disabled><select id="g-cbisel-off" disabled><option>unavailable</option></select></div>
</div>
</div>
</div>
<div class="g-sec">
<h2>Progress bars</h2>
<div class="cbi-progressbar" title="303.37 MiB / 484.11 MiB (62%)"><div style="width:62%"></div></div>
<div class="cbi-progressbar" title="3.46 MiB / 242.05 MiB (1%)"><div style="width:1%"></div></div>
<div class="cbi-progressbar" title="97%"><div style="width:97%"></div></div>
<!-- The three below are what window.progressbar() itself writes (menu-footstrap-common.js), not
hand-authored markup like the three above: role, aria-value* and — the reason these exist at
all — data-fs-level, which is the ONLY thing 25-progressbar.css's warn/danger rules match.
Nothing above carried that attribute, so computed-diff, a11y and export-tier never rendered
those two rules at all; a clean run proved nothing about them. None of the three carries
aria-label: none sits in a .cbi-value row or a table cell, so the function finds no label and
writes no attribute at all — the accessible name then falls back to `title`, the same reading
the visible `::after` shows (menu-footstrap-common.js, findProgressbarLabel). -->
<div class="cbi-progressbar" role="progressbar" aria-valuemin="0" aria-valuemax="100"
aria-valuenow="42" aria-valuetext="203.37 MiB / 484.11 MiB (42%)"
title="203.37 MiB / 484.11 MiB (42%)"><div style="width:42%"></div></div>
<div class="cbi-progressbar" data-fs-level="warn" role="progressbar" aria-valuemin="0" aria-valuemax="100"
aria-valuenow="85" aria-valuetext="411.49 MiB / 484.11 MiB (85%)"
title="411.49 MiB / 484.11 MiB (85%)"><div style="width:85%"></div></div>
<div class="cbi-progressbar" data-fs-level="danger" role="progressbar" aria-valuemin="0" aria-valuemax="100"
aria-valuenow="96" aria-valuetext="464.75 MiB / 484.11 MiB (96%)"
title="464.75 MiB / 484.11 MiB (96%)"><div style="width:96%"></div></div>
<!-- The same meter as the FIELD of a labelled CBI row, which is what an app building a form row
with ui.js emits (luci-app-modeminfo's Antennas page: one of these per figure). The six
above own their line and take the value above the bar; here the name is already in the left
column, so the value goes beside the meter instead. -->
<div class="cbi-value">
<label class="cbi-value-title">RSSI</label>
<div class="cbi-value-field"><div class="cbi-progressbar" title="-55.9 dBm"><div style="width:72%"></div></div></div>
</div>
<div class="cbi-value">
<label class="cbi-value-title">RSRP</label>
<div class="cbi-value-field"><div class="cbi-progressbar" title="-140.0 dBm"><div style="width:2%"></div></div></div>
</div>
<p class="g-note">What to look at: the two shapes read as one row each. In the bare meters the
value sits above the bar's right edge, pairing with whatever names the bar on the line above;
in a <code>.cbi-value</code> row it sits after the bar, on the row's own line, and the values
line up in a column because the reserve is fixed rather than shrink-wrapped.</p>
</div>
<div class="g-sec">
<h2>Badges &amp; status</h2>
<div class="g-row">
<span class="ifacebadge">eth0</span>
<span class="ifacebadge ifacebadge-active">br-lan</span>
<!-- The inline style is firewall.js's getZoneColorStyle() verbatim: it declares
--zone-color-rgb and then paints FROM it. Writing a plain `background:#a4d1a4` here
was wrong in a way only dark mode shows: base/95-luci.css re-mixes the zone colour at
.4/.3 for dark, reading that var with NO fallback (deliberately — see the comment
there), so the !important declaration went invalid at computed-value time and the
badge lost its colour entirely (measured: background rgba(0,0,0,0) in dark). -->
<span class="zonebadge" style="--zone-color-rgb:164, 209, 164; background-color:rgb(var(--zone-color-rgb))">lan</span>
<span class="zonebadge zonebadge-empty">empty</span>
<span class="label">label</span>
<span class="label notice">notice</span>
<span class="label warning">warning</span>
<span class="label danger">danger</span>
<span class="label success">success</span>
</div>
<div class="g-row">
<div class="ifacebox" style="min-width:70px;max-width:120px">
<div class="ifacebox-head center active">wan</div>
<div class="ifacebox-body left">1000 Mbit/s<br>▲ 1.2 GiB ▼ 8.3 GiB</div>
</div>
<div class="ifacebox" style="min-width:70px;max-width:120px">
<div class="ifacebox-head center">lan2</div>
<div class="ifacebox-body left">no link</div>
</div>
</div>
<!-- The Overview's upstream plates (30_network.js): the SAME .ifacebox classes as the port
tiles above, wrapped in .network-status-table, and the reason that wrapper is styled
separately — these lines are ADDRESSES. The IPv6 one is deliberately uncompressed and
full length, because that is the case that broke: `.ifacebox` is `white-space: nowrap`,
an address has no spaces to wrap at, and the plate is a flex item that shrinks — so the
address printed outside its own border (measured at a 390px viewport: 45px past it).
Narrow the window here and the text must stay inside the box.
The third plate is the RU HEAD, the sibling bug: the head inherits the same `nowrap` and
had no override at all, so a sentence-length caption (LuCI's RU string for "IPv4 Upstream")
ran straight out of the box: measured at 320px/Density Large, 301px in a 288px column,
41.3px past #view, with the .cbi-section clipped by 42px and no scroller. EN has no fixture
this long because EN never broke. -->
<div class="network-status-table">
<div class="ifacebox">
<div class="ifacebox-head center active"><strong>IPv4 Upstream</strong></div>
<div class="ifacebox-body left">
<span><strong>Protocol:</strong> Static address<br><strong>Address:</strong> 203.0.113.42/24<br><strong>Gateway:</strong> 203.0.113.1<br><strong>DNS:</strong> 9.9.9.9<br><strong>Connected:</strong> 8d 12h 0m 57s</span>
<div><span class="ifacebadge"><img src="/luci-static/resources/icons/ethernet.svg" width="16" alt=""><span><strong>Device:</strong> Ethernet Adapter: "wwan0"<br><strong>MAC address:</strong> C6:68:C6:D1:EF:25</span></span></div>
</div>
</div>
<div class="ifacebox">
<div class="ifacebox-head center active"><strong>IPv6 Upstream</strong></div>
<div class="ifacebox-body left">
<span><strong>Protocol:</strong> Static address<br><strong>Prefix Delegated:</strong> 2001:db8:1a2b:3c4e:5e6f:7a8b:9c0d::/64<br><strong>Address:</strong> 2001:db8:1a2b:3c4d:5e6f:7a8b:9c0d:1e2f/64<br><strong>Gateway:</strong> 2001:db8:1a2b:3c4d::1<br><strong>Connected:</strong> 8d 12h 0m 57s</span>
<div><span class="ifacebadge"><img src="/luci-static/resources/icons/ethernet.svg" width="16" alt=""><span><strong>Device:</strong> Ethernet Adapter: "wwan0"<br><strong>MAC address:</strong> C6:68:C6:D1:EF:25</span></span></div>
</div>
</div>
<div class="ifacebox">
<div class="ifacebox-head center active"><strong>Подключение IPv4 (внешняя сеть)</strong></div>
<div class="ifacebox-body left">
<span><strong>Протокол:</strong> Статический адрес<br><strong>Адрес:</strong> 203.0.113.42/24<br><strong>Шлюз:</strong> 203.0.113.1<br><strong>Соединение:</strong> 8d 12h 0m 57s</span>
</div>
</div>
</div>
<div class="g-row">
<span class="cbi-tooltip-container">hover for tooltip<span class="cbi-tooltip">The tooltip body.</span></span>
<em class="spinning">Loading…</em>
</div>
</div>
<div class="g-sec">
<h2>Key/value table (no id) — System, Memory</h2>
<div class="cbi-section">
<h3>System</h3>
<div class="table">
<div class="tr"><div class="td">Hostname</div><div class="td">OpenWrt</div></div>
<div class="tr"><div class="td">Model</div><div class="td">Netcore N60 Pro</div></div>
<div class="tr"><div class="td">Uptime</div><div class="td">8d 14h 11m 18s</div></div>
</div>
</div>
</div>
<div class="g-sec">
<h2>Data table (with id) — leases, processes</h2>
<p class="g-note">On a live page fs-select.js measures this one and cards it when it stops fitting.
The gallery has no LuCI JS, so it is shown in both states: the table in a scroll frame (gallery
chrome, so a narrow viewport does not drag the whole page sideways), and the card below it.</p>
<div class="cbi-section g-overwide" tabindex="0" role="group" aria-label="Active DHCPv4 Leases (scrolls)">
<h3>Active DHCPv4 Leases</h3>
<!-- `.fs-dt` is written by fs-select.js on a live page (a table with a header row that is not
a config table). The gallery has no LuCI JS, so the fixture carries the tag it would be
given — without it, every rule the measured tier owns (the floor, the sticky header, the
card) is unrepresented here and tools/table-contract.mjs has nothing to measure. -->
<table class="table fs-dt" id="status_leases">
<tr class="tr table-titles">
<th class="th">Hostname</th><th class="th">IPv4 address</th>
<th class="th">MAC</th><th class="th">Lease time</th><th class="th">&nbsp;</th>
</tr>
<!-- `data-title` on every cell, because `ui.Table` writes it on a live page and the card
view prints it: a fixture without it renders a card of unlabelled values, which is
the exact defect the contract gate exists to catch. -->
<tr class="tr cbi-rowstyle-1">
<td class="td" data-title="Hostname">Watch</td><td class="td" data-title="IPv4 address">10.11.12.131</td>
<td class="td" data-title="MAC">E6:EF:BA:54:DE:32</td><td class="td" data-title="Lease time">9h 10m 51s</td>
<td class="td"><button class="cbi-button cbi-button-apply">Reserve IP</button></td>
</tr>
<tr class="tr cbi-rowstyle-2">
<td class="td" data-title="Hostname">iPhone</td><td class="td" data-title="IPv4 address">10.11.12.182</td>
<td class="td" data-title="MAC">F4:A3:10:96:E0:53</td><td class="td" data-title="Lease time">8h 56m 4s</td>
<td class="td"><button class="cbi-button cbi-button-apply">Reserve IP</button></td>
</tr>
<!-- …and the SAME button mid-click, which is the shape issue #22 was reported on:
40_dhcp.js adds `spinning` to the button it was clicked on, and this row is inside
a `.table[id]`, whose cell-button rule is (0,5,0) — the rule that used to out-rank
the spinner's padding ladder and draw the glyph on top of the label. Rendered here
so a regression is visible without a router (issue #15 was the same bug one rule
lower down, and neither was in the gallery). -->
<tr class="tr cbi-rowstyle-1">
<td class="td" data-title="Hostname">nb-ivan</td><td class="td" data-title="IPv4 address">10.11.12.144</td>
<td class="td" data-title="MAC">A2:9C:41:07:BB:10</td><td class="td" data-title="Lease time">2h 28m 11s</td>
<td class="td cbi-section-actions"><button class="cbi-button cbi-button-apply spinning" disabled>Reserve IP</button></td>
</tr>
</table>
</div>
<!-- the same table in the state the fitter puts it in when it stops fitting: `.fs-stacked` is the
card, and it is what a phone actually gets. Rendered here so the card's own rules — the
`data-title` eyebrow, the half-width cell pairs, the full-width actions row — are visible and
measurable without a router. -->
<div class="cbi-section">
<h3>…and the same table carded (.fs-stacked)</h3>
<table class="table fs-dt fs-stacked" id="status_leases_carded">
<tr class="tr table-titles">
<th class="th">Hostname</th><th class="th">IPv4 address</th>
<th class="th">MAC</th><th class="th">Lease time</th><th class="th">&nbsp;</th>
</tr>
<tr class="tr cbi-rowstyle-1">
<td class="td" data-title="Hostname">Watch</td><td class="td" data-title="IPv4 address">10.11.12.131</td>
<td class="td" data-title="MAC">E6:EF:BA:54:DE:32</td><td class="td" data-title="Lease time">9h 10m 51s</td>
<td class="td cbi-section-actions"><button class="cbi-button cbi-button-apply">Reserve IP</button></td>
</tr>
</table>
</div>
<!-- Two more header shapes fs-select.js's adoptMarkup() produces from a real <thead> — neither
is `.tr.table-titles`, which is what the frame's corner-rounding rule (30-tables.css) used
to key on alone, so the top corners stayed square under either shape (forum post #141). -->
<div class="cbi-section g-overwide" tabindex="0" role="group" aria-label="Framed table, thead with bare th, no tr (scrolls)">
<h3>Header row: &lt;thead&gt; with &lt;th&gt; directly inside, no &lt;tr&gt; (issue #141)</h3>
<!-- include/20_lan.js (DHCP table) builds its header with E(), which appends the <th>s
straight to the <thead> — the parser's implied <tr> never happens, so the header cells
are the thead's own direct children. -->
<table class="table fs-dt" id="table_head_bare_thead">
<thead class="thead">
<th class="th">Host</th><th class="th">IPv4 address</th><th class="th">MAC</th>
</thead>
<tr class="tr cbi-rowstyle-1">
<td class="td" data-title="Host">router</td><td class="td" data-title="IPv4 address">10.0.0.1</td>
<td class="td" data-title="MAC">AA:BB:CC:DD:EE:FF</td>
</tr>
</table>
</div>
<div class="cbi-section g-overwide" tabindex="0" role="group" aria-label="Framed table, thead wrapping a real tr (scrolls)">
<h3>Header row: &lt;thead&gt; wrapping a real &lt;tr&gt;, no .table-titles (issue #141)</h3>
<!-- include/30_wifi.js (Wi-Fi table) nests a real <tr> inside <thead>; adoptMarkup() marks
the <thead> itself `.thead` but never adds `.tr`/`.table-titles` to the row nested
inside it, so a selector keyed on `.tr.table-titles` alone misses this shape too. -->
<table class="table fs-dt" id="table_head_thead_tr">
<thead class="thead">
<tr class="dashboard-bg">
<th class="th">SSID</th><th class="th">Channel</th><th class="th">Signal</th>
</tr>
</thead>
<tr class="tr cbi-rowstyle-1">
<td class="td" data-title="SSID">HomeNet</td><td class="td" data-title="Channel">36</td>
<td class="td" data-title="Signal">-52 dBm</td>
</tr>
</table>
</div>
<h2>The shapes that broke it — one fixture per report</h2>
<!-- These are not decoration: each is the exact value that produced a bug, kept so the contract
gate (tools/table-contract.mjs) has something to squeeze. A theme with no floor starves the
column holding one of these, and the browser then reports, truthfully, that the table fits. -->
<div class="cbi-section g-overwide" tabindex="0" role="group" aria-label="Data table with the reported values (scrolls)">
<h3>Data table with the reported values</h3>
<table class="table fs-dt" id="pathological">
<tr class="tr table-titles">
<th class="th">Client</th><th class="th">MAC</th><th class="th">Encryption</th>
<th class="th">Key</th><th class="th">Package</th>
</tr>
<tr class="tr cbi-rowstyle-1">
<!-- a hyphenated identifier: `break-word` may use the hyphen, which is why
pages/30-software.css still nowraps the package name -->
<td class="td">Yandex-Station-Midi-61QK</td>
<!-- a colon is NOT a break opportunity: this is one 17-character token (issue #5) -->
<td class="td">48:E1:5C:9F:26:20</td>
<!-- slash and hyphen ARE break opportunities: the canvas floor used to measure this
at 144px against a real 93 (which is why it carded tables that had room) -->
<td class="td">WPA2-PSK/CCMP</td>
<!-- 130 characters with nothing to break on: luci-app-3ginfo-lite's ICCID shape -->
<td class="td">qHMcYUJ3RJ5Zr1Qz8xN2vB7kL0pW6sT4dG9fH1jK3mP5nR7tV9xZ2bC4eF6gJ8lN0qS2uW4yA6cE8hK1mO3rT5vX7zB9dG1jL3nQ5sU7wY9aD2fH4kM6pR8tV0x</td>
<td class="td">luci-app-attendedsysupgrade</td>
</tr>
</table>
</div>
<div class="cbi-section g-overwide" tabindex="0" role="group" aria-label="Key/value with an unbreakable value (scrolls)">
<h3>Key/value with an unbreakable value (issue #36)</h3>
<!-- luci-app-dockerman prints JSON.stringify() of everything `docker info` returns. This tier
has no header row, so it can never card: `overflow-wrap: anywhere` is what keeps it inside
the card, and that is the half of the split base/40-tables.css owns. -->
<div class="table">
<div class="tr"><div class="td">ENTRYPOINT</div><div class="td">xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx</div></div>
<div class="tr"><div class="td">Driver</div><div class="td">overlay2</div></div>
</div>
</div>
<div class="cbi-section g-overwide" tabindex="0" role="group" aria-label="Log without a header row (scrolls)">
<h3>No header row — a log, which scrolls rather than cards</h3>
<!-- `.fs-xscroll` is written by fs-select.js when such a table is MEASURED to overflow and
holds no widget. Carried here so the pinned first column and the focus ring are visible
without a router. -->
<div class="table fs-xscroll" tabindex="0" role="group" aria-label="System log (sample)">
<div class="tr">
<div class="td nowrap">2026-08-14 20:43:11</div><div class="td nowrap">daemon.notice</div>
<div class="td nowrap">netifd</div>
<div class="td nowrap">Interface wan is now up, protocol dhcp, device eth0.2, metric 0</div>
</div>
<div class="tr">
<div class="td nowrap">2026-08-14 20:43:12</div><div class="td nowrap">daemon.warn</div>
<div class="td nowrap">dnsmasq</div>
<div class="td nowrap">reducing DNS packet size for nameserver 2001:db8:1a2b:3c4d:5e6f:7a8b:9c0d:1e2f to 1280</div>
</div>
</div>
</div>
<h2>The forcing utilities (.left/.right/.center + .top/.middle/.bottom)</h2>
<!-- LuCI writes these on a cell to OVERRIDE the table's own alignment — `<td class="td right">`
— which is why they carry an `!important`: a bare `.right` (0,1,0) loses to the theme's
`.cbi-section .table .td` (0,3,0) on specificity alone. Six of the eight flags in
styles/base are these. Nothing rendered them, so stripping the flags showed no diff and
read as "the flags do nothing". They do. (They are not the theme's ONLY flags outside the
reduced-motion block, which is what this comment used to claim: there are 22 of those, each
fighting an inline `style=` or an unlayered `<style>` — docs/conventions.md accounts for
every one.) -->
<div class="cbi-section g-overwide" tabindex="0" role="group" aria-label="Forcing utilities table (scrolls)">
<table class="table" id="forcing-utils">
<tr class="tr table-titles">
<th class="th left">left</th><th class="th center">center</th><th class="th right">right</th>
<th class="th top">top</th><th class="th middle">middle</th><th class="th bottom">bottom</th>
</tr>
<tr class="tr cbi-rowstyle-1">
<td class="td left">left</td>
<td class="td center">center</td>
<td class="td right">right</td>
<td class="td top">top<br>two lines</td>
<td class="td middle">middle<br>two lines</td>
<td class="td bottom">bottom<br>two lines</td>
</tr>
</table>
</div>
</div>
<div class="g-sec">
<h2>Key/value table, narrow container (System -&gt; System at 390px)</h2>
<p class="g-note">The @container fs-content query below only fires inside a container narrower than
560px. `.g-wrap` (this page's only other fs-content container) never gets that narrow, so
without a fixture of its own this rule is unrendered and computed-diff reads 0 for it. The
340px `.cbi-map` here stands in for the sidebar layout's cramped content column.</p>
<div class="cbi-map" style="max-width: 340px">
<div class="cbi-section">
<h3>System</h3>
<div class="table">
<div class="tr"><div class="td">Firmware Version</div><div class="td">OpenWrt 24.10.5 r28134-cb8087f118</div></div>
<div class="tr"><div class="td">Kernel Version</div><div class="td">6.6.104</div></div>
<div class="tr"><div class="td">Architecture</div><div class="td">ARMv8 Processor rev 4</div></div>
</div>
</div>
</div>
</div>
<div class="g-sec">
<h2>CBI section table — firewall zones, port forwards</h2>
<div class="cbi-section cbi-tblsection">
<h3>Zones</h3>
<table class="table cbi-section-table">
<tr class="tr cbi-section-table-titles">
<th class="th cbi-section-table-cell">Zone</th>
<th class="th cbi-section-table-cell">Input</th>
<th class="th cbi-section-table-cell cbi-section-actions">&nbsp;</th>
</tr>
<tr class="tr cbi-section-table-row cbi-rowstyle-1">
<td class="td cbi-value-field cbi-section-table-cell">lan</td>
<td class="td cbi-value-field cbi-section-table-cell">accept</td>
<td class="td cbi-section-table-cell nowrap cbi-section-actions">
<button class="cbi-button drag-handle center">☰</button>
<button class="btn cbi-button cbi-button-edit">Edit</button>
<button class="btn cbi-button cbi-button-remove">Delete</button>
</td>
</tr>
<tr class="tr cbi-section-table-row cbi-rowstyle-2">
<td class="td cbi-value-field cbi-section-table-cell">wan</td>
<td class="td cbi-value-field cbi-section-table-cell">reject</td>
<td class="td cbi-section-table-cell nowrap cbi-section-actions">
<button class="cbi-button drag-handle center">☰</button>
<button class="btn cbi-button cbi-button-edit">Edit</button>
<button class="btn cbi-button cbi-button-remove">Delete</button>
</td>
</tr>
</table>
<div class="cbi-section-create"><button class="cbi-button cbi-button-add">Add</button></div>
</div>
</div>
<div class="g-sec">
<h2>CBI section table — header reflow at the card boundary (RU, task 0163)</h2>
<p class="g-note">`.cbi-map` pinned to 968px — firewall/zones' own measured content column at
1024px, live, RU. The card fires on THIS element (theme/45-misc.css), one level above
`.cbi-section`'s own `--fs-card-pad`, so 968px here only hands the table 936px. The six real
header strings below need ~1017px at the word floor (base/40-tables.css) — before the 992px
fix (theme/65-dropdown.css) this table stayed a table at 968px and clipped its own header row
with no scroller in reach; 968 now clears 992 and the table cards instead.</p>
<div class="cbi-map" style="max-width: 968px">
<div class="cbi-section cbi-tblsection">
<h3>Зоны</h3>
<table class="table cbi-section-table">
<tr class="tr cbi-section-table-titles">
<th class="th cbi-section-table-cell">Зона ⇒ Перенаправления</th>
<th class="th cbi-section-table-cell">Входящий трафик</th>
<th class="th cbi-section-table-cell">Исходящий трафик</th>
<th class="th cbi-section-table-cell">Внутризональная пересылка</th>
<th class="th cbi-section-table-cell">Маскарадинг IPv4</th>
<th class="th cbi-section-table-cell cbi-section-actions">&nbsp;</th>
</tr>
<tr class="tr cbi-section-table-row cbi-rowstyle-1">
<td class="td cbi-value-field cbi-section-table-cell">lan</td>
<td class="td cbi-value-field cbi-section-table-cell">принимать</td>
<td class="td cbi-value-field cbi-section-table-cell">принимать</td>
<td class="td cbi-value-field cbi-section-table-cell">да</td>
<td class="td cbi-value-field cbi-section-table-cell">нет</td>
<td class="td cbi-section-table-cell nowrap cbi-section-actions">
<button class="btn cbi-button cbi-button-edit">Правка</button>
</td>
</tr>
</table>
</div>
</div>
</div>
<!-- task 0147 (second RU sweep), finding 3: base/95-luci.css sizes each row-action button
`flex: 1 1 4em` inside `.td.cbi-section-actions`, a plain <table>/.td markup (Startup, not
form.js's .cbi-section-table above) with no per-row carding. A definite flex-basis caps the
automatic minimum at 4em once the row runs out of room, so a caption longer than that shrinks
the BOX below its own text instead of the text — theme/55-buttons.css's ellipsis is what stops
the overflow from painting onto the next button. Captions are the real Startup strings. -->
<div class="g-sec">
<h2>Row actions, plain table (Startup-style, no .cbi-section-table)</h2>
<div class="cbi-section">
<div class="table" style="max-width: 340px">
<div class="tr">
<div class="td">dnsmasq</div>
<div class="td cbi-section-actions">
<form>
<button class="cbi-button cbi-button-positive">Запустить</button>
<button class="cbi-button">Перезапустить</button>
<button class="cbi-button cbi-button-negative">Остановить</button>
<button class="cbi-button">Принудительно завершить</button>
</form>
</div>
</div>
</div>
</div>
</div>
<div class="g-sec">
<h2>Pager</h2>
<div class="pager center">
<a class="btn prev" href="#">«</a>
<div class="text">Page 2 of 7</div>
<a class="btn next" href="#">»</a>
</div>
</div>
<div class="g-sec">
<h2>The chrome bar (#indicators, .fs-themerow, .fs-logout)</h2>
<!-- The bar's own controls, in the shape the chrome really emits: the buttons come from
ucode/template/themes/footstrap/partials/search.ut and logout.ut, and #indicators is the
empty div header.ut leaves for LuCI (`<div id="indicators" class="fs-indicators">`), which
ui.js fills with a BARE `<span data-indicator=…>` — no class, which is exactly why a theme
rule written against `.label` painted nothing and no gate noticed. Nothing here needs the
chrome JS: fs-chrome.js only adds .fs-ind-compact/.fs-bar-actrow by measurement, and their
absence is the ordinary wide-bar case this fixture stands for. -->
<div class="fs-sidebar" style="position:static">
<div class="fs-indicators" id="indicators">
<span data-indicator="poll-status" data-style="active">Refreshing</span>
<span data-indicator="uci-changes" data-clickable="true">Unsaved: 3</span>
</div>
<button type="button" class="fs-themerow" aria-label="Search">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M16.5 16.5 21 21"/></svg>
<span class="fs-label">Search</span>
</button>
<a class="fs-logout" href="#" aria-label="Log out">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M10 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4"/><path d="M14 12h7m0 0-3-3m3 3-3 3"/></svg>
<span class="fs-label">Log out</span>
</a>
</div>
</div>
<div class="g-sec">
<h2>Modal</h2>
<!-- `opacity`/`visibility`, not just `position`/`display`: base/60-modal.css hides the overlay
until <body> carries `modal-overlay-active`, so this whole section rendered NOTHING — and
axe skips what is invisible, which meant the a11y gate had never once measured a dialog.
The body class is not usable here: it also sets `overflow: hidden; height: 100dvh`. -->
<div id="modal_overlay" style="position:static;display:block;background:transparent;padding:0;opacity:1;visibility:visible">
<div class="modal" style="margin:0">
<h4>Modal title</h4>
<p>Modal body — this is what a confirmation or an edit form looks like.</p>
<div class="right button-row">
<button class="cbi-button cbi-button-neutral">Dismiss</button>
<button class="cbi-button cbi-button-positive important">Confirm</button>
</div>
</div>
<!-- flash.js's "Flash image?" dialog: prose inside `label.btn`, and a checksum `<li>`
with no break opportunity. Both used to force the overlay to scroll sideways. -->
<div class="modal" style="margin:1em 0 0">
<h4>Flash image?</h4>
<p>The flash image was uploaded. Below is the checksum and file size listed, compare them with the original file to ensure data integrity.</p>
<ul>
<li>Size: 8.25 MB</li>
<li>SHA256: 5d27b68d801a4569696991c2435437508dff558b0c1f5d27b68d801a4569696b</li>
</ul>
<p><label class="btn"><input type="checkbox" checked> Keep settings and retain the current configuration</label></p>
<p><label class="btn"><input type="checkbox"> Include in backup a list of current installed packages at /etc/backup/installed_packages.txt</label></p>
<p class="alert-message danger"><label>Image check failed:<br><br>Invalid image type</label></p>
<p><label class="btn alert-message danger"><input type="checkbox"> Force upgrade<br><br>Select 'Force upgrade' to flash the image even if the image format check fails. Use only if you are sure that the firmware is correct and meant for your device!</label></p>
<div class="right button-row">
<button class="cbi-button cbi-button-neutral">Cancel</button>
<button class="cbi-button cbi-button-action important">Continue</button>
</div>
</div>
<!-- Reproduces luci-mod-dashboard's `.Dashboard … .devices-info .tr .td:nth-child(3) {
position:relative; top:-3px }` (issue #141, post #141): fs-sheets.js re-hosts an app's
<style> into `@layer theme`, so the app rule below meets 30-tables.css's `:is(#view,
#modal_overlay) .fs-dt .td { top: auto; bottom: auto }` in the SAME layer, and it is
specificity that decides — the mock selector carries the 7 classes the report measured,
none of them an id, so footstrap's rule (one id, two classes) wins with no
`!important`. Placed under `#modal_overlay` because that counter-rule, unlike the
corner-rounding one above, has no bare `table.fs-dt` fallback: every real table already
sits under `#view` or `#modal_overlay` (docs/css.md, "the card contract"), so the id is
load-bearing, not decorative, and a fixture outside both roots would not exercise it. -->
<style>
@layer theme {
.g-mock-a.g-mock-b.g-mock-c.g-mock-d.g-mock-e .td:nth-child(2) { position: relative; top: -3px; }
}
</style>
<div class="modal g-mock-a g-mock-b g-mock-c g-mock-d g-mock-e" style="margin:1em 0 0">
<h4>Row separator vs. an app's offset cell</h4>
<table class="table fs-dt" id="table_head_offset_td">
<tr class="tr table-titles">
<th class="th">Client</th><th class="th">RSSI</th><th class="th">Uptime</th>
</tr>
<tr class="tr cbi-rowstyle-1">
<td class="td" data-title="Client">iPhone</td>
<td class="td" data-title="RSSI">-52 dBm</td>
<td class="td" data-title="Uptime">2h 14m</td>
</tr>
</table>
</div>
</div>
</div>
<div class="g-sec">
<h2>Overview card header — disclosure control (pages/20-overview.css)</h2>
<!-- fs-overview.js wireDisclosure() promotes the card heading to role="button", with the
keyboard-only pieces (tabindex, aria-expanded, aria-controls) plus a hand cursor and a
focus ring from the theme (pages/20-overview.css). The wrapper differs by release
(fs-overview.js headerEl()): 25.12 wraps the heading in `.cbi-title`, 24.10 leaves a bare
`<h3>` as the section's own first child, and the theme rule has to reach both — this is
the specimen that measures it, computed-diff otherwise reading 0 for this file. Appended
after Modal; nothing above is touched or reordered. -->
<div class="cbi-section">
<div class="cbi-title">
<h3 role="button" tabindex="0" aria-controls="g-ovl-panel-1" aria-expanded="true">System
<span class="label" data-indicator="poll-status" data-style="inactive" aria-hidden="true">Hide</span>
</h3>
</div>
<div id="g-ovl-panel-1">
<div class="table">
<div class="tr"><div class="td">Hostname</div><div class="td">OpenWrt</div></div>
<div class="tr"><div class="td">Model</div><div class="td">Generic</div></div>
</div>
</div>
</div>
<div class="cbi-section">
<h3 role="button" tabindex="0" aria-expanded="false">Memory
<span class="label" data-indicator="poll-status" data-style="active" aria-hidden="true">Show</span>
</h3>
</div>
</div>
</div>
</body>
</html>