diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61cdce6d..1dbf5fd7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,7 +53,7 @@ jobs: - name: apt install run: | sudo apt-get update - sudo apt-get install --no-install-suggests --no-install-recommends inkscape icoutils scour optipng node-less node-source-map + sudo apt-get install --no-install-suggests --no-install-recommends inkscape icoutils scour optipng - uses: actions/setup-python@v5.2.0 with: diff --git a/README.md b/README.md index 4eaf9438..7f530fe8 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,7 @@ The [conventions document](./conventions.md) describes some idioms used in the c Grab the build dependencies with: $ sudo apt install brotli inkscape icoutils git scour optipng \ - python3-dev build-essential \ - node-less node-source-map + python3-dev build-essential [Install pip](https://pip.pypa.io/en/latest/installing/#get-pip), then install [Tox](http://tox.readthedocs.org/en/latest/). (I actually recommend installing this in your home directory, but that's outside the scope of this document.) diff --git a/bin/compile-static.py b/bin/compile-static.py index fff71f48..3df27565 100755 --- a/bin/compile-static.py +++ b/bin/compile-static.py @@ -1,5 +1,5 @@ #!/usr/bin/python3 -# Copyright © 2018, 2019, 2020, 2022 Tom Most +# Copyright © 2018, 2019, 2020, 2022, 2024 Tom Most # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -49,13 +49,16 @@ import re import shlex from asyncio.subprocess import PIPE -from dataclasses import dataclass +from collections import deque +from dataclasses import dataclass, field from pathlib import Path from shutil import rmtree from typing import Optional, Sequence import brotli +import tinycss2 import zopfli.gzip +from tinycss2.ast import AtRule, ParseError repo_root = Path(__file__).parent.parent @@ -244,39 +247,57 @@ async def process_svg(svg: Path, w: Writer) -> None: w.add_file_bytes(hashname(svg.stem, "svg", svg_bytes), svg_bytes) -async def process_less(less: Path, build_dir: Path, w: Writer) -> None: +async def process_css(css: Path, w: Writer) -> None: """ - Convert .less to a CSS file and source map. + Process a stylesheet to inline stylesheets referenced via @include rules. """ - css_path = build_dir / f"{less.stem}.css" - map_path = build_dir / f"{less.stem}.css.map" - await _run( - [ - "/usr/bin/node", - "/usr/bin/lessc", - "--no-js", - "--strict-imports", - f"--source-map={map_path}", - str(less), - str(css_path), - ] - ) - css = css_path.read_bytes() - css_name = hashname(less.stem, "css", css) - map_name = f"{css_name}.map" + combiner = CombinedCss(root_dir=css.parent) + combiner.include(css.name, "utf-8") + + combined = combiner.serialize().encode("utf-8") + w.add_file_bytes(hashname(css.stem, "css", combined), combined) + - # We must change the source map reference on the last line when renaming - # the file. - last_line_index = css.rindex(b"\n") + 1 - last_line = css[last_line_index:] - if not last_line.startswith(b"/*# sourceMappingURL="): - raise Exception("Expected sourceMappingURL comment at the end of {css_path}, but found {last_line!r}") +@dataclass +class CombinedCss: + root_dir: Path + _included: set[str] = field(default_factory=set, init=False) + _rules: list[object] = field(default_factory=list, init=False) + + def include(self, path: str, protocol_encoding=None, environment_encoding=None) -> None: + if path in self._included: + # print(f"Skipping duplicate @import {path!r}") + return + else: + # TODO: Should normalize the path to avoid dupe includes + # TODO: Should verify the path is within root_dir + # print(f"Including rules from @import {path!r}") + self._included.add(path) + + with (self.root_dir / path).open("rb") as f: + rules, encoding = tinycss2.parse_stylesheet_bytes( + f.read(), + protocol_encoding, + environment_encoding, + ) - # ❤ copies - css = css[:last_line_index] + f"/*# sourceMappingURL={map_name} */".encode() + for rule in rules: + if rule.type == "at-rule" and rule.lower_at_keyword == "import": + # We only support the form `@import "./foo". The full syntax is quite complex: + # https://developer.mozilla.org/en-US/docs/Web/CSS/@import + [ws, urlish] = rule.prelude + assert ws.type == "whitespace" + assert urlish.type == "string" + self.include(urlish.value) + elif rule.type == "parse-error": + # Note that very little counts as a parse error in CSS, so + # this is not a useful diagnostic tool. + raise ValueError(f"Parse error in {path}: {rule.message}") + else: + self._rules.append(rule) - w.add_file_bytes(css_name, css) - w.add_file(map_name, map_path) + def serialize(self): + return tinycss2.serialize(self._rules) async def process_glob(paths: Path, w: Writer) -> None: @@ -347,7 +368,7 @@ async def _main(build_dir: Path, out_dir: Path, compress: bool) -> None: process_svg(repo_root / "img" / "lettertype.svg", w), process_svg(repo_root / "img" / "logotype.svg", w), process_glob((repo_root / "vendor" / "normalize.css").glob("normalize-*.css"), w), - process_less(repo_root / "less" / "main.less", build_dir, w), + process_css(repo_root / "css" / "main.css", w), process_fonts(repo_root, w), ) print(w.summarize()) diff --git a/less/Article.less b/css/Article.css similarity index 96% rename from less/Article.less rename to css/Article.css index 1e172e38..80c2d14e 100644 --- a/less/Article.less +++ b/css/Article.css @@ -1,6 +1,3 @@ -@import "./include.less"; - - .article-header { display: grid; grid-template-columns: 1fr min-content; @@ -17,6 +14,7 @@ .tools { display: flex; flex-flow: row nowrap; + --focus-offset: var(--focus-offset-in); /* Don't clip focus ring. */ .icon { width: 2em; diff --git a/less/FeedView.less b/css/FeedView.css similarity index 93% rename from less/FeedView.less rename to css/FeedView.css index 50fbe91d..ef77b4a7 100644 --- a/less/FeedView.less +++ b/css/FeedView.css @@ -1,5 +1,3 @@ -@import "./include.less"; - .feed-header { h1 { margin: 1rem 0; diff --git a/less/GlobalBar.less b/css/GlobalBar.css similarity index 90% rename from less/GlobalBar.less rename to css/GlobalBar.css index 6b304024..4a08fa30 100644 --- a/less/GlobalBar.less +++ b/css/GlobalBar.css @@ -1,5 +1,3 @@ -@import "./include.less"; - .yarrharr-masthead { display: flex; flex: 0 0 auto; @@ -36,8 +34,6 @@ display: flex; align-items: center; justify-content: center; - - .flat-button; - + --focus-offset: var(--focus-offset-in); /* Don't clip focus ring. */ padding: 1rem; /* Ensure spacing between */ } diff --git a/less/ListArticle.less b/css/ListArticle.css similarity index 77% rename from less/ListArticle.less rename to css/ListArticle.css index d3f90c4a..08a06afd 100644 --- a/less/ListArticle.less +++ b/css/ListArticle.css @@ -1,11 +1,8 @@ -@import "./include.less"; - .list-article { margin: 0.75rem 0; } .list-article-inner { - // max-width: var(--layout-max-width); margin: 0 auto; overflow: hidden; } @@ -14,14 +11,14 @@ display: flex; flex-flow: row nowrap; align-items: stretch; + --focus-offset: var(--focus-offset-in); /* Don't clip focus ring. */ .outbound { - .flat-button; - padding: ((2 * @button_border_width)); + padding: calc(2 * var(--button-border-width)); flex: 1 1 100%; width: 100%; display: grid; - // XXX: Using grid here is starting to look a little silly. + /* XXX: Using grid here is starting to look a little silly. */ grid-template-areas: "meta1" "meta2"; @@ -37,12 +34,10 @@ color: var(--quiet-text-color); text-transform: uppercase; font-size: smaller; - // background: red; } .meta2 { grid-area: meta2; font-family: "Newsreader"; - // background: blue; } .meta1, .meta2 { @@ -61,25 +56,21 @@ read-toggle, .view-link { - flex: 0 0 @bar_height; + flex: 0 0 var(--bar-height); } read-toggle { button { - .flat-button; width: 100%; height: 100%; } - // background: purple; } .view-link { - // background: magenta; - .flat-button; } .outbound-icon, read-toggle button, .view-link { - font-size: @icon_size; + font-size: var(--icon-size); display: flex; flex-flow: row nowrap; align-items: center; @@ -87,7 +78,6 @@ .icon { flex: 0 0 auto; - // background: white; display: block; align-self: center; justify-self: center; diff --git a/less/StateToggle.less b/css/StateToggle.css similarity index 85% rename from less/StateToggle.less rename to css/StateToggle.css index 4e43ad5e..8b198de6 100644 --- a/less/StateToggle.less +++ b/css/StateToggle.css @@ -1,7 +1,7 @@ fave-toggle { button { - .flat-button; - padding: 1px 4px; + background: transparent; + border: none; } .inactive { @@ -22,8 +22,8 @@ fave-toggle { read-toggle { button { - .flat-button; - padding: 1px 4px; + background: transparent; + border: none; } .inactive { diff --git a/less/Tabs.less b/css/Tabs.css similarity index 93% rename from less/Tabs.less rename to css/Tabs.css index aaadb3ad..46dc1211 100644 --- a/less/Tabs.less +++ b/css/Tabs.css @@ -1,5 +1,3 @@ -@import "./include.less"; - /** * * @@ -29,7 +27,7 @@ border-radius: 4px 4px 0 0; text-decoration: none; line-height: 1.7; - padding: 0.125rem 8px 0 8px; + padding: 0.125rem 8px 2px 8px; white-space: nowrap; text-align: center; text-overflow: ellipsis; diff --git a/less/icons.less b/css/icons.css similarity index 100% rename from less/icons.less rename to css/icons.css diff --git a/less/inventory.less b/css/inventory.css similarity index 81% rename from less/inventory.less rename to css/inventory.css index 692cd0a3..11250f67 100644 --- a/less/inventory.less +++ b/css/inventory.css @@ -1,5 +1,3 @@ -@import "./include.less"; - .inventory-centered { max-width: var(--layout-max-width); margin: 0 auto; @@ -42,7 +40,7 @@ align-content: center; a { display: block; - padding: 0.25rem; // increase clickable area + padding: 0.25rem; /* increase clickable area */ } svg { display: block; @@ -52,7 +50,7 @@ } } -// On wide displays, show a table with scannable columns. +/* On wide displays, show a table with scannable columns. */ @media screen and (min-width: 32rem) { .feed-list { display: grid; @@ -83,7 +81,7 @@ font-size: smaller; } - // Zebra stripes. Have to handle the header specially. + /* Zebra stripes. Have to handle the header specially. */ .feed-list-item-header > th { font: inherit; background: var(--footer-background-color); @@ -94,7 +92,7 @@ } } -// On narrow displays display a fatter row suitable for touchscreens. +/* On narrow displays display a fatter row suitable for touchscreens. */ @media screen and (max-width: 32rem) { .feed-list { display: grid; @@ -106,7 +104,7 @@ grid: "feed unread edit" min-content "changed fave edit" min-content / 1fr min-content min-content; - padding: 0.75rem @text_padding; + padding: 0.75rem var(--text-padding); & > .col-feed { grid-area: feed; padding: 0 0.5rem 0 0; } & > .col-unread { grid-area: unread; padding: 0 0.5rem; text-align: right; } @@ -119,26 +117,26 @@ & > .col-edit { grid-area: edit; padding: 0 0 0 0.5rem; } } - // Hide the header, as we have duplicated its function inline. + /* Hide the header, as we have duplicated its function inline. */ .feed-list-item-header { display: none; } - // Zebra stripes + /* Zebra stripes */ .feed-list-item:nth-child(odd) { background: var(--footer-background-color); } } .label-list-item { - // & > :nth-child(1) { background: hsla(160, 60%, 50%, 0.5); } - // & > :nth-child(2) { background: hsla(120, 60%, 50%, 0.5); } - // & > :nth-child(3) { background: hsla(060, 60%, 50%, 0.5); } - // & > :nth-child(4) { background: hsla(190, 60%, 50%, 0.5); } - // & > :nth-child(5) { background: hsla(000, 60%, 50%, 0.5); } - // & > :nth-child(6) { background: hsla(040, 60%, 50%, 0.5); } - - // Icon + /*& > :nth-child(1) { background: hsla(160, 60%, 50%, 0.5); }*/ + /*& > :nth-child(2) { background: hsla(120, 60%, 50%, 0.5); }*/ + /*& > :nth-child(3) { background: hsla(060, 60%, 50%, 0.5); }*/ + /*& > :nth-child(4) { background: hsla(190, 60%, 50%, 0.5); }*/ + /*& > :nth-child(5) { background: hsla(000, 60%, 50%, 0.5); }*/ + /*& > :nth-child(6) { background: hsla(040, 60%, 50%, 0.5); }*/ + + /* Icon */ & > :nth-child(1) { display: flex; align-items: center; @@ -148,26 +146,26 @@ } } - // Label name + /* Label name */ & > :nth-child(2) { } - // Feed count + /* Feed count */ & > :nth-child(3) { } - // Unread count + /* Unread count */ & > :nth-child(4) { } - // Fave count + /* Fave count */ & > :nth-child(5) { } - // Edit button + /* Edit button */ & > :nth-child(6) { a { display: block; - padding: 0.25rem; // increase clickable area + padding: 0.25rem; /* increase clickable area */ } svg { display: block; @@ -177,7 +175,7 @@ } } -// On wide displays, show a table with scannable columns. +/* On wide displays, show a table with scannable columns. */ @media screen and (min-width: 32rem) { .label-list { display: grid; @@ -207,7 +205,7 @@ font-size: smaller; } - // Zebra stripes. Have to handle the header specially. + /* Zebra stripes. Have to handle the header specially. */ .label-list-item-header > th { font-weight: normal; background: var(--footer-background-color); @@ -218,7 +216,7 @@ } } -// On narrow displays display a fatter row suitable for touchscreens. +/* On narrow displays display a fatter row suitable for touchscreens. */ @media screen and (max-width: 32rem) { .label-list { display: grid; @@ -230,7 +228,7 @@ grid: "icon title unread edit" min-content "icon feeds fave edit" min-content / min-content 1fr min-content min-content; - padding: 0.75rem @text_padding; + padding: 0.75rem var(--text-padding); & > td:nth-child(1) { grid-area: icon; padding: 0 0.5rem 0 0; } & > td:nth-child(2) { grid-area: title; } @@ -243,12 +241,12 @@ & > td:nth-child(6) { grid-area: edit; padding: 0 0 0 0.5rem; } } - // Hide the header, as we have duplicated its function inline. + /* Hide the header, as we have duplicated its function inline. */ .label-list-item-header { display: none; } - // Zebra stripes + /* Zebra stripes */ .label-list-item:nth-child(odd) { background: var(--footer-background-color); } @@ -278,7 +276,7 @@ } input[type=submit] { flex: 1 1 0; - margin-left: @text_padding; + margin-left: var(--text-padding); } } diff --git a/less/main.less b/css/main.css similarity index 72% rename from less/main.less rename to css/main.css index 557b2162..f5a834d1 100644 --- a/less/main.less +++ b/css/main.css @@ -1,10 +1,8 @@ /** * Global base styles for the Yarrharr UI. */ -@import "./include.less"; - :root { - // Column width whenever a narrow layout is desired. + /* Column width whenever a narrow layout is desired. */ --layout-max-width: 50rem; --background-color: white; @@ -21,11 +19,27 @@ --heart-color: #c92ccc; --check-color: #39cc5c; - --focus-color: #0098ff; --selected-color: hsl(24.1, 100%, 49.2%); --font-wdth-mono: 100; --font-weight-mono: 400; + + /* By default, the focus ring is offset so it doesn't bump against text ("out"). + In packed UI contexts, it is inset relative to layout ("in"). */ + --focus-color: #0098ff; + --focus-offset-out: 2px; + --focus-offset-in: -2px; + --focus-offset: var(--focus-offset-out); + + /* There is always this much padding between text and the edge of stuff. */ + --text-padding: 0.25rem; + + /* Buttons have thick borders, also used to indicate focus. */ + --button-border-width: 2px; + + --icon-size: 2.0rem; + --bar-line-height: 1.25rem; + --bar-height: calc(2 * var(--bar-line-height) + 2 * var(--text-padding) + 2 * var(--button-border-width)); } @media screen and (prefers-color-scheme: dark) { @@ -40,8 +54,9 @@ --icon-color: #eee; --icon-color-off: #666; - // Invert the logo image so that it is legible in the dark theme. We - // don't go 100% because it is pretty bold. + /* Invert the logo image so that it is legible in the dark theme. We + * don't go 100% because it is pretty bold. + */ --logo-filter: invert(90%); --font-weight-mono: 300; @@ -88,7 +103,7 @@ a:visited { color: var(--text-color); } a:active { - --underline-color: rgb(185, 28, 199); // TODO Make this more vibrant + --underline-color: rgb(185, 28, 199); /* TODO Make this more vibrant */ } a:link[aria-disabled=true], a:visited[aria-disabled=true] { @@ -106,31 +121,37 @@ h1 { hyphens: auto; } +/* Default focus styles. */ :focus-visible { outline: 2px solid var(--focus-color); + outline-offset: var(--focus-offset); +} +/* Same as above, but with higher precedence to address a specificity conflict with normalize.css. */ +button:-moz-focusring, +[type="button"]:-moz-focusring, +[type="reset"]:-moz-focusring, +[type="submit"]:-moz-focusring { + outline: 2px solid var(--focus-color); + outline-offset: var(--focus-offset); } /** * Base form styles * */ -.form-item-layout() { - display: block; - box-sizing: border-box; - width: 100%; - height: 2.5rem; - padding: 0 @text_padding; - font: normal 0.9rem / 2.1rem sans-serif; -} - label:not(.checkbox) { display: block; font-size: 0.9rem; color: var(--quiet-text-color); + padding: 0 0 var(--text-padding) 0; } label.checkbox { - .form-item-layout; + display: block; + box-sizing: border-box; + width: 100%; + height: 2.5rem; + padding: 0 var(--text-padding); input[type=checkbox] { display: inline-block; @@ -142,11 +163,15 @@ input[type=password], input[type=url], input[type=email], select[multiple] { - .form-item-layout; + display: block; + box-sizing: border-box; + width: 100%; + height: 2.5rem; + padding: 0 var(--text-padding); color: var(--text-color); background: var(--background-color); - border: @button_border_width solid var(--border-color); + border: var(--button-border-width) solid var(--border-color); transition: border-color 0.05s; &:focus { @@ -164,7 +189,11 @@ select[multiple] { } .text-button { - .form-item-layout; + display: block; + box-sizing: border-box; + width: 100%; + height: 2.5rem; + padding: 0 var(--text-padding); cursor: pointer; white-space: nowrap; @@ -176,11 +205,15 @@ select[multiple] { color: var(--text-color); background: linear-gradient(to bottom, var(--gradient-start) 0%, var(--gradient-mid) 28%, var(--gradient-end) 100%); - border: @button_border_width solid var(--gradient-border-color); - border-radius: (1.5 * @text_padding); + border: var(--button-border-width) solid var(--gradient-border-color); + border-radius: calc(1.5 * var(--text-padding)); transition: border-color 0.05s; - text-shadow: var(--gradient-border-color) 0 0 .025rem; /* bump contrast */ + /* bump contrast */ + text-shadow: var(--gradient-border-color) 0 0 .1rem, + var(--gradient-border-color) -1px -1px .01rem, + var(--gradient-border-color) 1px 1px .01rem; + box-shadow: inset var(--gradient-end) 0 0 .25rem; padding: 0 0.75rem; @@ -236,15 +269,18 @@ select[multiple] { .bar { display: flex; justify-content: space-between; - height: @bar_height; + height: var(--bar-height); max-width: var(--layout-max-width); margin: 0 auto; - line-height: @bar_line_height; + line-height: var(--bar-line-height); contain: size layout; + --focus-offset: var(--focus-offset-in); /* Don't clip focus ring. */ + & > a[href], & > button { - .flat-button; + background: transparent; + border: none; } & > * { @@ -256,27 +292,11 @@ select[multiple] { & > * > .icon { display: block; - font-size: @icon_size; - } - - & > .square, - & > .expand > .square { - flex: 0 0 @bar_height; - display: flex; - align-items: center; - justify-content: center; - } - - & > .expand { - flex: 1 1 0; - min-width: @bar_height; /* allow shrinking below content size */ + font-size: var(--icon-size); } - & > .header { - justify-content: start; - white-space: nowrap; - text-overflow: ellipsis; // TODO: get ellipsis working here. - overflow: hidden; + & > .square { + flex: 0 0 var(--bar-height); } } @@ -340,11 +360,11 @@ form.small-form { color: var(--footer-text-color); } -@import "./Article.less"; -@import "./GlobalBar.less"; -@import "./icons.less"; -@import "./ListArticle.less"; -@import "./StateToggle.less"; -@import "./Tabs.less"; -@import "./FeedView.less"; -@import "./inventory.less"; +@import "./Article.css"; +@import "./GlobalBar.css"; +@import "./icons.css"; +@import "./ListArticle.css"; +@import "./StateToggle.css"; +@import "./Tabs.css"; +@import "./FeedView.css"; +@import "./inventory.css"; diff --git a/less/include.less b/less/include.less deleted file mode 100644 index 6deb79c1..00000000 --- a/less/include.less +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Global variable definitions. - * - * This file must not contain any concrete styles. - */ - -/* There is always this much padding between text and the edge of stuff. */ -@text_padding: 0.25rem; - -/* Buttons have thick borders, also used to indicate focus. */ -@button_border_width: 2px; - -@icon_size: 2.0rem; - -@bar_line_height: 1.25rem; -@bar_height: calc(2 * @bar_line_height + 2 * @text_padding + 2 * @button_border_width); - -.flat-button() { - box-sizing: border-box; - border: none; - padding: 0; /* Otherwise Firefox assigns padding in button:active to simulate "press". */ - - background: transparent; - box-shadow: inset 0 0 0 0 transparent; - - &:focus-visible { - outline: transparent; - box-shadow: inset 0 0 0 @button_border_width var(--focus-color); - } -} diff --git a/requirements_static.in b/requirements_static.in index 4cc024df..44e66135 100644 --- a/requirements_static.in +++ b/requirements_static.in @@ -1,3 +1,3 @@ brotli +tinycss2 zopfli - diff --git a/requirements_static.txt b/requirements_static.txt index f274cea6..9ba7dec2 100644 --- a/requirements_static.txt +++ b/requirements_static.txt @@ -6,5 +6,9 @@ # brotli==1.1.0 # via -r requirements_static.in +tinycss2==1.3.0 + # via -r requirements_static.in +webencodings==0.5.1 + # via tinycss2 zopfli==0.2.3 # via -r requirements_static.in