fxcss 0.6.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fxcss/__init__.py +3 -0
- fxcss/__main__.py +6 -0
- fxcss/audit.py +595 -0
- fxcss/catalogue.py +385 -0
- fxcss/cli.py +558 -0
- fxcss/compare.py +189 -0
- fxcss/core.py +999 -0
- fxcss/fetch.py +255 -0
- fxcss/probe.py +220 -0
- fxcss-0.6.1.dist-info/METADATA +572 -0
- fxcss-0.6.1.dist-info/RECORD +15 -0
- fxcss-0.6.1.dist-info/WHEEL +5 -0
- fxcss-0.6.1.dist-info/entry_points.txt +2 -0
- fxcss-0.6.1.dist-info/licenses/LICENSE +21 -0
- fxcss-0.6.1.dist-info/top_level.txt +1 -0
fxcss/catalogue.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Build a browsable directory of the UI parts this theme can style.
|
|
3
|
+
|
|
4
|
+
For each landmark it resolves the real element in a live Firefox, crops its
|
|
5
|
+
region out of a screenshot, records the styles actually in effect, and greps
|
|
6
|
+
the theme for the rules that target it. The result is an HTML page answering
|
|
7
|
+
"what is this bit called, what does it look like, and which file do I edit?".
|
|
8
|
+
|
|
9
|
+
Everything here is measured from a running browser rather than assumed, so it
|
|
10
|
+
stays honest as Firefox changes: an element that no longer exists is reported
|
|
11
|
+
as missing instead of silently documented.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import html
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
21
|
+
|
|
22
|
+
# Curated because an exhaustive dump of every node in browser.xhtml would be
|
|
23
|
+
# noise. These are the parts a theme actually targets.
|
|
24
|
+
LANDMARKS = [
|
|
25
|
+
# area, slug, selector, name, description
|
|
26
|
+
("Toolbars", "nav-bar", "#nav-bar", "Navigation toolbar",
|
|
27
|
+
"The main toolbar holding back/forward, the address bar and the right-hand buttons."),
|
|
28
|
+
("Toolbars", "tabs-toolbar", "#TabsToolbar", "Tab strip toolbar",
|
|
29
|
+
"Container for the row of tabs. Themes often move or restyle this whole strip."),
|
|
30
|
+
("Toolbars", "personal-toolbar", "#PersonalToolbar", "Bookmarks toolbar",
|
|
31
|
+
"The bookmarks bar, shown here with three sample bookmarks."),
|
|
32
|
+
("Toolbars", "toolbar-menubar", "#toolbar-menubar", "Menu bar",
|
|
33
|
+
"Hidden by default on macOS; on Windows this is the File/Edit/View strip."),
|
|
34
|
+
|
|
35
|
+
("Address bar", "urlbar", "#urlbar", "Address bar container",
|
|
36
|
+
"The whole address bar, including its background, border and rounding."),
|
|
37
|
+
("Address bar", "urlbar-input", "#urlbar-input", "Address bar text field",
|
|
38
|
+
"The editable text itself. Colour problems with typed URLs live here."),
|
|
39
|
+
("Address bar", "identity-box", "#identity-box", "Site identity block",
|
|
40
|
+
"Padlock / permissions area at the left of the address bar."),
|
|
41
|
+
("Address bar", "star-button", "#star-button-box", "Bookmark star",
|
|
42
|
+
"The bookmark-this-page control at the right of the address bar."),
|
|
43
|
+
("Address bar", "urlbar-results", "#urlbar-results", "Address bar dropdown",
|
|
44
|
+
"Suggestion list shown while typing. Rendered inside the window, so it is themeable."),
|
|
45
|
+
|
|
46
|
+
("Tabs", "tabbrowser-tabs", "#tabbrowser-tabs", "Tab container",
|
|
47
|
+
"Wraps every tab plus the new-tab button."),
|
|
48
|
+
("Tabs", "selected-tab", ".tabbrowser-tab[selected]", "Selected tab",
|
|
49
|
+
"The active tab. Most tab colour work targets this."),
|
|
50
|
+
("Tabs", "pinned-container", "#pinned-tabs-container", "Pinned tabs container",
|
|
51
|
+
"Holds pinned tabs; spacing here affects the join with normal tabs."),
|
|
52
|
+
("Tabs", "tab-close", ".tab-close-button", "Tab close button",
|
|
53
|
+
"Per-tab close control. The install script can move it to the left."),
|
|
54
|
+
("Tabs", "newtab-button", "#tabs-newtab-button", "New tab button",
|
|
55
|
+
"The + at the end of the tab strip."),
|
|
56
|
+
|
|
57
|
+
("Toolbar buttons", "back-button", "#back-button", "Back button", "Navigation back."),
|
|
58
|
+
("Toolbar buttons", "reload-button", "#reload-button", "Reload button", "Reload / stop."),
|
|
59
|
+
("Toolbar buttons", "firefox-view", "#firefox-view-button", "Firefox View button",
|
|
60
|
+
"The leftmost control on the tab strip."),
|
|
61
|
+
("Toolbar buttons", "appmenu-button", "#PanelUI-menu-button", "App menu button",
|
|
62
|
+
"The hamburger. Its panel is a native OS window and cannot be screenshotted."),
|
|
63
|
+
("Toolbar buttons", "extensions-button", "#unified-extensions-button", "Extensions button",
|
|
64
|
+
"Unified extensions control."),
|
|
65
|
+
("Toolbar buttons", "account-button", "#fxa-toolbar-menu-button", "Account button",
|
|
66
|
+
"Firefox Account menu."),
|
|
67
|
+
|
|
68
|
+
("Find bar", "findbar", "findbar", "Find bar",
|
|
69
|
+
"Docked at the bottom of the content area when you press Ctrl/Cmd+F."),
|
|
70
|
+
|
|
71
|
+
("Content", "browser-stack", "#browser", "Content area",
|
|
72
|
+
"Where the page renders. Styled by userContent.css rather than userChrome.css."),
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
COMPUTED_PROPS = [
|
|
76
|
+
"background-color", "color", "border-radius", "border-top-width",
|
|
77
|
+
"border-top-color", "box-shadow", "font-size", "padding-top", "margin-inline-end",
|
|
78
|
+
"height", "opacity",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
RESOLVE = """
|
|
82
|
+
const [landmarks, props] = arguments;
|
|
83
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
84
|
+
const doc = win.document;
|
|
85
|
+
const out = [];
|
|
86
|
+
for (const [slug, selector] of landmarks) {
|
|
87
|
+
let el = null;
|
|
88
|
+
try { el = doc.querySelector(selector); } catch (e) {}
|
|
89
|
+
if (!el) { out.push({slug, found: false}); continue; }
|
|
90
|
+
const r = el.getBoundingClientRect();
|
|
91
|
+
const cs = win.getComputedStyle(el);
|
|
92
|
+
const styles = {};
|
|
93
|
+
for (const p of props) { styles[p] = cs.getPropertyValue(p); }
|
|
94
|
+
out.push({
|
|
95
|
+
slug, found: true,
|
|
96
|
+
tag: el.localName,
|
|
97
|
+
id: el.id || null,
|
|
98
|
+
classes: (typeof el.className === "string" ? el.className : "") || null,
|
|
99
|
+
rect: {x: r.x, y: r.y, w: r.width, h: r.height},
|
|
100
|
+
visible: r.width > 0 && r.height > 0,
|
|
101
|
+
styles,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def css_references(repo: Path, selector: str):
|
|
109
|
+
"""Find where the theme styles this selector.
|
|
110
|
+
|
|
111
|
+
Matches on the id or class token rather than the literal selector string,
|
|
112
|
+
since a rule is far more likely to read `#urlbar[focused]` or
|
|
113
|
+
`#nav-bar > .foo` than to repeat the selector verbatim.
|
|
114
|
+
"""
|
|
115
|
+
token = selector.lstrip(".#[").split("[")[0].split(">")[0].strip()
|
|
116
|
+
if not token:
|
|
117
|
+
return []
|
|
118
|
+
pattern = re.compile(r"(?<![\w-])[.#]?" + re.escape(token) + r"(?![\w-])")
|
|
119
|
+
hits = []
|
|
120
|
+
for path in sorted((repo / "chrome").rglob("*.css")):
|
|
121
|
+
try:
|
|
122
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
123
|
+
except OSError:
|
|
124
|
+
continue
|
|
125
|
+
for n, line in enumerate(lines, 1):
|
|
126
|
+
stripped = line.strip()
|
|
127
|
+
if not stripped or stripped.startswith("/*"):
|
|
128
|
+
continue
|
|
129
|
+
if pattern.search(line):
|
|
130
|
+
hits.append({
|
|
131
|
+
"file": str(path.relative_to(repo)),
|
|
132
|
+
"line": n,
|
|
133
|
+
"text": stripped[:160],
|
|
134
|
+
})
|
|
135
|
+
return hits
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def annotate(overview: Image.Image, items, dpr):
|
|
139
|
+
"""Draw numbered boxes over the overview screenshot."""
|
|
140
|
+
img = overview.convert("RGB")
|
|
141
|
+
draw = ImageDraw.Draw(img, "RGBA")
|
|
142
|
+
try:
|
|
143
|
+
font = ImageFont.load_default(size=int(15 * dpr))
|
|
144
|
+
except TypeError:
|
|
145
|
+
font = ImageFont.load_default()
|
|
146
|
+
|
|
147
|
+
for idx, item in enumerate(items, 1):
|
|
148
|
+
if not item.get("visible"):
|
|
149
|
+
continue
|
|
150
|
+
r = item["rect"]
|
|
151
|
+
box = [r["x"] * dpr, r["y"] * dpr, (r["x"] + r["w"]) * dpr, (r["y"] + r["h"]) * dpr]
|
|
152
|
+
if box[2] - box[0] < 2 or box[3] - box[1] < 2:
|
|
153
|
+
continue
|
|
154
|
+
draw.rectangle(box, outline=(255, 0, 128, 255), width=max(2, int(dpr)))
|
|
155
|
+
draw.rectangle([box[0], box[1], box[0] + 26 * dpr, box[1] + 18 * dpr],
|
|
156
|
+
fill=(255, 0, 128, 235))
|
|
157
|
+
draw.text((box[0] + 5 * dpr, box[1] + 2 * dpr), str(idx), fill=(255, 255, 255), font=font)
|
|
158
|
+
return img
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def crop(overview: Image.Image, rect, dpr, pad=8):
|
|
162
|
+
x0 = max(0, int((rect["x"] - pad) * dpr))
|
|
163
|
+
y0 = max(0, int((rect["y"] - pad) * dpr))
|
|
164
|
+
x1 = min(overview.width, int((rect["x"] + rect["w"] + pad) * dpr))
|
|
165
|
+
y1 = min(overview.height, int((rect["y"] + rect["h"] + pad) * dpr))
|
|
166
|
+
if x1 - x0 < 4 or y1 - y0 < 4:
|
|
167
|
+
return None
|
|
168
|
+
piece = overview.crop((x0, y0, x1, y1))
|
|
169
|
+
if piece.width > 760:
|
|
170
|
+
piece = piece.resize((760, round(piece.height * 760 / piece.width)), Image.LANCZOS)
|
|
171
|
+
return piece
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def build(session, repo: Path, outdir: Path, self_contained=False):
|
|
175
|
+
from . import core
|
|
176
|
+
|
|
177
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
session.setup_window()
|
|
179
|
+
# The find bar is created lazily, so it does not exist as an element until
|
|
180
|
+
# opened. Open it before measuring or it would be reported as missing.
|
|
181
|
+
session.m.script(core.OPEN_FINDBAR)
|
|
182
|
+
time.sleep(1.2)
|
|
183
|
+
session.m.script(core.RESET_FINDBAR)
|
|
184
|
+
time.sleep(0.6)
|
|
185
|
+
|
|
186
|
+
info = session.info()
|
|
187
|
+
dpr = info["dpr"]
|
|
188
|
+
|
|
189
|
+
entries = {}
|
|
190
|
+
for mode in ("light", "dark"):
|
|
191
|
+
session.set_dark(mode == "dark")
|
|
192
|
+
time.sleep(2.0)
|
|
193
|
+
mode_dir = outdir / mode
|
|
194
|
+
mode_dir.mkdir(exist_ok=True)
|
|
195
|
+
|
|
196
|
+
import io
|
|
197
|
+
overview = Image.open(io.BytesIO(session.m.screenshot()))
|
|
198
|
+
resolved = session.m.script(
|
|
199
|
+
RESOLVE, [[[l[1], l[2]] for l in LANDMARKS], COMPUTED_PROPS])
|
|
200
|
+
by_slug = {r["slug"]: r for r in resolved}
|
|
201
|
+
|
|
202
|
+
ordered = [by_slug[l[1]] for l in LANDMARKS if l[1] in by_slug]
|
|
203
|
+
annotate(overview, ordered, dpr).save(mode_dir / "overview.png", optimize=True)
|
|
204
|
+
|
|
205
|
+
for area, slug, selector, name, desc in LANDMARKS:
|
|
206
|
+
item = by_slug.get(slug, {"slug": slug, "found": False})
|
|
207
|
+
entry = entries.setdefault(slug, {
|
|
208
|
+
"area": area, "slug": slug, "selector": selector,
|
|
209
|
+
"name": name, "description": desc,
|
|
210
|
+
"references": css_references(repo, selector),
|
|
211
|
+
"modes": {},
|
|
212
|
+
})
|
|
213
|
+
shot = None
|
|
214
|
+
if item.get("visible"):
|
|
215
|
+
piece = crop(overview, item["rect"], dpr)
|
|
216
|
+
if piece:
|
|
217
|
+
shot = f"{mode}/{slug}.png"
|
|
218
|
+
piece.save(mode_dir / f"{slug}.png", optimize=True)
|
|
219
|
+
entry["modes"][mode] = {
|
|
220
|
+
"found": item.get("found", False),
|
|
221
|
+
"visible": item.get("visible", False),
|
|
222
|
+
"styles": item.get("styles", {}),
|
|
223
|
+
"shot": shot,
|
|
224
|
+
"tag": item.get("tag"),
|
|
225
|
+
}
|
|
226
|
+
print(f" {mode}: {sum(1 for r in resolved if r.get('visible'))}"
|
|
227
|
+
f"/{len(LANDMARKS)} landmarks visible", flush=True)
|
|
228
|
+
|
|
229
|
+
catalogue = {"info": info, "entries": list(entries.values())}
|
|
230
|
+
(outdir / "catalogue.json").write_text(json.dumps(catalogue, indent=2), encoding="utf-8")
|
|
231
|
+
page = render_html(catalogue)
|
|
232
|
+
(outdir / "index.html").write_text(page, encoding="utf-8")
|
|
233
|
+
if self_contained:
|
|
234
|
+
(outdir / "catalogue.html").write_text(inline_images(page, outdir), encoding="utf-8")
|
|
235
|
+
return catalogue
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def inline_images(page: str, base: Path):
|
|
239
|
+
"""Rewrite image references as data URIs so the page is one shareable file."""
|
|
240
|
+
import base64
|
|
241
|
+
|
|
242
|
+
def repl(match):
|
|
243
|
+
rel = match.group(1)
|
|
244
|
+
path = base / rel
|
|
245
|
+
if not path.exists():
|
|
246
|
+
return match.group(0)
|
|
247
|
+
data = base64.b64encode(path.read_bytes()).decode("ascii")
|
|
248
|
+
return f"src='data:image/png;base64,{data}'"
|
|
249
|
+
|
|
250
|
+
return re.sub(r"src='([^']+\.png)'", repl, page)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
PAGE_CSS = """
|
|
254
|
+
:root { color-scheme: light dark; --bg:#fff; --fg:#1c1c1e; --muted:#6b6b70;
|
|
255
|
+
--line:#e3e3e6; --card:#fafafa; --accent:#315bef; }
|
|
256
|
+
@media (prefers-color-scheme: dark) {
|
|
257
|
+
:root { --bg:#1c1c1e; --fg:#f2f2f7; --muted:#9a9aa0; --line:#38383c;
|
|
258
|
+
--card:#232326; --accent:#6f8dff; }
|
|
259
|
+
}
|
|
260
|
+
* { box-sizing: border-box; }
|
|
261
|
+
body { margin:0; background:var(--bg); color:var(--fg); font:15px/1.55 -apple-system,
|
|
262
|
+
BlinkMacSystemFont,'Segoe UI',sans-serif; }
|
|
263
|
+
.wrap { max-width: 1080px; margin: 0 auto; padding: 32px 20px 80px; }
|
|
264
|
+
h1 { font-size: 28px; margin: 0 0 6px; letter-spacing: -0.02em; }
|
|
265
|
+
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .08em;
|
|
266
|
+
color: var(--muted); margin: 40px 0 12px; }
|
|
267
|
+
.lede { color: var(--muted); margin: 0 0 28px; }
|
|
268
|
+
img { max-width: 100%; border-radius: 8px; border: 1px solid var(--line); display:block; }
|
|
269
|
+
.overview { margin-bottom: 8px; }
|
|
270
|
+
.tabs { display:flex; gap:8px; margin: 0 0 12px; }
|
|
271
|
+
.tabs button { font: inherit; padding: 6px 14px; border-radius: 999px; cursor: pointer;
|
|
272
|
+
border: 1px solid var(--line); background: var(--card); color: var(--fg); }
|
|
273
|
+
.tabs button[aria-selected=true] { background: var(--accent); color:#fff; border-color:transparent; }
|
|
274
|
+
.card { border:1px solid var(--line); border-radius:12px; padding:16px 18px;
|
|
275
|
+
margin-bottom:14px; background:var(--card); }
|
|
276
|
+
.card h3 { margin:0 0 2px; font-size:17px; }
|
|
277
|
+
.card .sel { font: 13px ui-monospace,SFMono-Regular,Menlo,monospace; color: var(--accent); }
|
|
278
|
+
.card p { margin:8px 0 12px; color: var(--muted); }
|
|
279
|
+
.styles { display:flex; flex-wrap:wrap; gap:6px; margin:10px 0 0; }
|
|
280
|
+
.chip { font: 12px ui-monospace,SFMono-Regular,Menlo,monospace; background:var(--bg);
|
|
281
|
+
border:1px solid var(--line); border-radius:6px; padding:3px 8px; }
|
|
282
|
+
.sw { display:inline-block; width:10px; height:10px; border-radius:2px; margin-right:5px;
|
|
283
|
+
border:1px solid rgba(128,128,128,.5); vertical-align:-1px; }
|
|
284
|
+
details { margin-top:10px; }
|
|
285
|
+
summary { cursor:pointer; color:var(--muted); font-size:13px; }
|
|
286
|
+
table { border-collapse:collapse; width:100%; margin-top:8px; font-size:13px; }
|
|
287
|
+
td { border-top:1px solid var(--line); padding:5px 8px; vertical-align:top; }
|
|
288
|
+
td.loc { white-space:nowrap; font: 12px ui-monospace,Menlo,monospace; color:var(--accent); }
|
|
289
|
+
td.src { font: 12px ui-monospace,Menlo,monospace; color:var(--muted); }
|
|
290
|
+
.missing { opacity:.55; }
|
|
291
|
+
.badge { font-size:11px; border:1px solid var(--line); border-radius:5px;
|
|
292
|
+
padding:1px 6px; color:var(--muted); margin-left:8px; }
|
|
293
|
+
"""
|
|
294
|
+
|
|
295
|
+
PAGE_JS = """
|
|
296
|
+
function showMode(m) {
|
|
297
|
+
document.querySelectorAll('[data-mode]').forEach(function (el) {
|
|
298
|
+
el.hidden = el.dataset.mode !== m;
|
|
299
|
+
});
|
|
300
|
+
document.querySelectorAll('.tabs button').forEach(function (b) {
|
|
301
|
+
b.setAttribute('aria-selected', String(b.dataset.set === m));
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
document.addEventListener('DOMContentLoaded', function () { showMode('light'); });
|
|
305
|
+
"""
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _swatch(value):
|
|
309
|
+
v = (value or "").strip()
|
|
310
|
+
if v.startswith(("rgb", "#")) and "0, 0, 0, 0" not in v:
|
|
311
|
+
return f'<span class="sw" style="background:{html.escape(v)}"></span>'
|
|
312
|
+
return ""
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def render_html(catalogue):
|
|
316
|
+
info = catalogue["info"]
|
|
317
|
+
parts = [
|
|
318
|
+
"<!doctype html><meta charset=utf-8>",
|
|
319
|
+
"<meta name=viewport content='width=device-width,initial-scale=1'>",
|
|
320
|
+
"<title>Themeable UI directory</title>",
|
|
321
|
+
f"<style>{PAGE_CSS}</style><div class=wrap>",
|
|
322
|
+
"<h1>Themeable UI directory</h1>",
|
|
323
|
+
f"<p class=lede>Measured live in Firefox {html.escape(str(info.get('version')))} "
|
|
324
|
+
f"on {html.escape(str(info.get('os')))}. Each entry shows what the part looks "
|
|
325
|
+
"like, the styles in effect, and which files in this repo style it.</p>",
|
|
326
|
+
"<div class=tabs>"
|
|
327
|
+
"<button data-set=light onclick=\"showMode('light')\">Light</button>"
|
|
328
|
+
"<button data-set=dark onclick=\"showMode('dark')\">Dark</button></div>",
|
|
329
|
+
]
|
|
330
|
+
for mode in ("light", "dark"):
|
|
331
|
+
parts.append(f"<div data-mode={mode} hidden>"
|
|
332
|
+
f"<img class=overview src='{mode}/overview.png' "
|
|
333
|
+
f"alt='Annotated {mode} browser window'></div>")
|
|
334
|
+
|
|
335
|
+
by_area = {}
|
|
336
|
+
for e in catalogue["entries"]:
|
|
337
|
+
by_area.setdefault(e["area"], []).append(e)
|
|
338
|
+
|
|
339
|
+
for area, items in by_area.items():
|
|
340
|
+
parts.append(f"<h2>{html.escape(area)}</h2>")
|
|
341
|
+
for e in items:
|
|
342
|
+
any_found = any(m["found"] for m in e["modes"].values())
|
|
343
|
+
cls = "card" if any_found else "card missing"
|
|
344
|
+
parts.append(f"<div class='{cls}'>")
|
|
345
|
+
badge = "" if any_found else "<span class=badge>not present in this Firefox</span>"
|
|
346
|
+
parts.append(f"<h3>{html.escape(e['name'])}{badge}</h3>")
|
|
347
|
+
parts.append(f"<div class=sel>{html.escape(e['selector'])}</div>")
|
|
348
|
+
parts.append(f"<p>{html.escape(e['description'])}</p>")
|
|
349
|
+
|
|
350
|
+
for mode in ("light", "dark"):
|
|
351
|
+
m = e["modes"].get(mode, {})
|
|
352
|
+
parts.append(f"<div data-mode={mode} hidden>")
|
|
353
|
+
if m.get("shot"):
|
|
354
|
+
parts.append(f"<img src='{m['shot']}' alt='{html.escape(e['name'])}'>")
|
|
355
|
+
elif any_found:
|
|
356
|
+
parts.append("<p class=sel>present but not visible in this layout</p>")
|
|
357
|
+
chips = []
|
|
358
|
+
for prop, val in (m.get("styles") or {}).items():
|
|
359
|
+
if not val or val in ("none", "0px", "auto", "normal", "rgba(0, 0, 0, 0)"):
|
|
360
|
+
continue
|
|
361
|
+
chips.append(f"<span class=chip>{_swatch(val)}"
|
|
362
|
+
f"{html.escape(prop)}: {html.escape(val)}</span>")
|
|
363
|
+
if chips:
|
|
364
|
+
parts.append("<div class=styles>" + "".join(chips) + "</div>")
|
|
365
|
+
parts.append("</div>")
|
|
366
|
+
|
|
367
|
+
refs = e["references"]
|
|
368
|
+
if refs:
|
|
369
|
+
parts.append(f"<details><summary>{len(refs)} rule"
|
|
370
|
+
f"{'s' if len(refs) != 1 else ''} in this repo</summary><table>")
|
|
371
|
+
for r in refs[:60]:
|
|
372
|
+
parts.append(
|
|
373
|
+
f"<tr><td class=loc>{html.escape(r['file'])}:{r['line']}</td>"
|
|
374
|
+
f"<td class=src>{html.escape(r['text'])}</td></tr>")
|
|
375
|
+
if len(refs) > 60:
|
|
376
|
+
parts.append(f"<tr><td class=loc>…</td><td class=src>"
|
|
377
|
+
f"and {len(refs) - 60} more</td></tr>")
|
|
378
|
+
parts.append("</table></details>")
|
|
379
|
+
else:
|
|
380
|
+
parts.append("<details><summary>no rules in this repo target it"
|
|
381
|
+
"</summary><p>Styled by Firefox defaults only.</p></details>")
|
|
382
|
+
parts.append("</div>")
|
|
383
|
+
|
|
384
|
+
parts.append(f"</div><script>{PAGE_JS}</script>")
|
|
385
|
+
return "\n".join(parts)
|