From 9c751911274e11da2d5af8a97033b0323f2678b9 Mon Sep 17 00:00:00 2001
From: Iain
Date: Tue, 6 Oct 2026 19:16:00 +0100
Subject: [PATCH 1/2] feat: card view by default, with a switch back to the
table
Every data page now leads with cards that state the facts plainly, and a
Cards | Table switch at the top right brings the table back. It's two radios,
so it works without JS, and the choice is remembered between pages.
- Device pages show the bootloader and stock updates up top, then a card per
ROM: Android version, Google apps, build date and patch, relock, what it
needs first, what works, and the install guide
- Device, brand and ROM pages list devices as cards grouped by brand, with one
filter bar driving cards and table alike, plus new ROM and bootloader filters
- Wording comes from one place, src/lib/facts.js: inferred answers read
"Likely doesn't work", old reports say when they were last checked, and the
origin jargon is gone. Unknown features stay off cards, and show in the table
- A design system: tokens, base and shared component styles, Lucide icons, and
brand and ROM logos (Simple Icons plus the five ROMs' own marks) with initials
as the fallback
- The API output is unchanged
Co-Authored-By: Claude Opus 5.5
---
.github/CONTRIBUTING.md | 4 +-
.github/README.md | 2 +-
package-lock.json | 10 +
package.json | 1 +
public/icons/broken.svg | 2 +-
public/icons/ended.svg | 2 +-
public/icons/na.svg | 2 +-
public/icons/note.svg | 2 +-
public/icons/partial.svg | 2 +-
public/icons/sort-down.svg | 2 +-
public/icons/sort-up.svg | 2 +-
public/icons/sort.svg | 2 +-
public/icons/stale.svg | 2 +-
public/icons/unknown.svg | 2 +-
public/icons/working.svg | 2 +-
src/assets/logos/README.md | 38 ++++
src/assets/logos/brands/asus.svg | 1 +
src/assets/logos/brands/fairphone.svg | 1 +
src/assets/logos/brands/google.svg | 1 +
src/assets/logos/brands/htc.svg | 1 +
src/assets/logos/brands/huawei.svg | 1 +
src/assets/logos/brands/lenovo.svg | 1 +
src/assets/logos/brands/lg.svg | 1 +
src/assets/logos/brands/meizu.svg | 1 +
src/assets/logos/brands/motorola.svg | 1 +
src/assets/logos/brands/nokia.svg | 1 +
src/assets/logos/brands/nvidia.svg | 1 +
src/assets/logos/brands/oneplus.svg | 1 +
src/assets/logos/brands/oppo.svg | 1 +
src/assets/logos/brands/razer.svg | 1 +
src/assets/logos/brands/samsung.svg | 1 +
src/assets/logos/brands/solana.svg | 1 +
src/assets/logos/brands/sony.svg | 1 +
src/assets/logos/brands/xiaomi.svg | 1 +
src/assets/logos/roms/calyxos.svg | 1 +
src/assets/logos/roms/crdroid.svg | 1 +
src/assets/logos/roms/eos.svg | 1 +
src/assets/logos/roms/evolutionx.svg | 1 +
src/assets/logos/roms/grapheneos.svg | 1 +
src/assets/logos/roms/iodeos.svg | 1 +
src/assets/logos/roms/lineageos.svg | 1 +
src/assets/logos/roms/pixelos.svg | 1 +
src/components/cards/CardGrid.astro | 143 ++++++++++++++
src/components/cards/FeatureList.astro | 70 +++++++
src/components/cards/RomCard.astro | 156 +++++++++++++++
src/components/cards/RomSummary.astro | 74 ++++++++
src/components/layout/Base.astro | 14 +-
src/components/layout/Footer.astro | 8 +-
src/components/layout/Header.astro | 5 +-
src/components/layout/PageHead.astro | 63 ++++++
src/components/table/Cell.astro | 68 ++-----
src/components/table/ColumnPicker.astro | 97 ++++++++++
src/components/table/Controls.astro | 158 ---------------
src/components/table/Matrix.astro | 45 +----
src/components/ui/DeviceList.astro | 57 ++++++
src/components/ui/DeviceTables.astro | 21 --
src/components/ui/Facts.astro | 73 ++++++-
src/components/ui/FilterBar.astro | 153 +++++++++++++++
src/components/ui/Icon.astro | 51 +++--
src/components/ui/Logo.astro | 76 ++++++++
src/components/ui/RomFeatures.astro | 58 ------
src/components/ui/ViewSwitch.astro | 107 +++++++++++
src/lib/cards.js | 50 +++++
src/lib/data/model.js | 4 +-
src/lib/dates.js | 15 ++
src/lib/facts.js | 167 ++++++++++++++++
src/lib/table.js | 119 ++++++------
src/lib/text.js | 2 +
src/pages/about.astro | 8 +-
src/pages/api/[name].json.js | 2 +-
src/pages/devices/[brand]/[slug].astro | 236 ++++++++++++++---------
src/pages/devices/[brand]/index.astro | 46 +++--
src/pages/devices/index.astro | 71 +++++--
src/pages/index.astro | 52 +++--
src/pages/roms/[rom].astro | 158 +++++++++------
src/pages/roms/index.astro | 60 ++++--
src/scripts/filter.js | 67 +++++++
src/scripts/matrix.js | 52 +----
src/styles/{global.css => base.css} | 84 +-------
src/styles/tokens.css | 83 ++++++++
src/styles/ui.css | 243 ++++++++++++++++++++++++
tests/facts.test.js | 118 ++++++++++++
tests/logos.test.js | 24 +++
83 files changed, 2448 insertions(+), 813 deletions(-)
create mode 100644 src/assets/logos/README.md
create mode 100644 src/assets/logos/brands/asus.svg
create mode 100644 src/assets/logos/brands/fairphone.svg
create mode 100644 src/assets/logos/brands/google.svg
create mode 100644 src/assets/logos/brands/htc.svg
create mode 100644 src/assets/logos/brands/huawei.svg
create mode 100644 src/assets/logos/brands/lenovo.svg
create mode 100644 src/assets/logos/brands/lg.svg
create mode 100644 src/assets/logos/brands/meizu.svg
create mode 100644 src/assets/logos/brands/motorola.svg
create mode 100644 src/assets/logos/brands/nokia.svg
create mode 100644 src/assets/logos/brands/nvidia.svg
create mode 100644 src/assets/logos/brands/oneplus.svg
create mode 100644 src/assets/logos/brands/oppo.svg
create mode 100644 src/assets/logos/brands/razer.svg
create mode 100644 src/assets/logos/brands/samsung.svg
create mode 100644 src/assets/logos/brands/solana.svg
create mode 100644 src/assets/logos/brands/sony.svg
create mode 100644 src/assets/logos/brands/xiaomi.svg
create mode 100644 src/assets/logos/roms/calyxos.svg
create mode 100644 src/assets/logos/roms/crdroid.svg
create mode 100644 src/assets/logos/roms/eos.svg
create mode 100644 src/assets/logos/roms/evolutionx.svg
create mode 100644 src/assets/logos/roms/grapheneos.svg
create mode 100644 src/assets/logos/roms/iodeos.svg
create mode 100644 src/assets/logos/roms/lineageos.svg
create mode 100644 src/assets/logos/roms/pixelos.svg
create mode 100644 src/components/cards/CardGrid.astro
create mode 100644 src/components/cards/FeatureList.astro
create mode 100644 src/components/cards/RomCard.astro
create mode 100644 src/components/cards/RomSummary.astro
create mode 100644 src/components/layout/PageHead.astro
create mode 100644 src/components/table/ColumnPicker.astro
delete mode 100644 src/components/table/Controls.astro
create mode 100644 src/components/ui/DeviceList.astro
delete mode 100644 src/components/ui/DeviceTables.astro
create mode 100644 src/components/ui/FilterBar.astro
create mode 100644 src/components/ui/Logo.astro
delete mode 100644 src/components/ui/RomFeatures.astro
create mode 100644 src/components/ui/ViewSwitch.astro
create mode 100644 src/lib/cards.js
create mode 100644 src/lib/facts.js
create mode 100644 src/scripts/filter.js
rename src/styles/{global.css => base.css} (53%)
create mode 100644 src/styles/tokens.css
create mode 100644 src/styles/ui.css
create mode 100644 tests/facts.test.js
create mode 100644 tests/logos.test.js
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 438a59f..14425ef 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -27,7 +27,7 @@ The files in `upstream/` get rewritten by a bot every Monday, so any change you
## Reporting what works
-The [form](https://github.com/NotAFlightRisk/android-rom-compat/issues/new?template=report-feature.yml) is the easy way. Pick a device and ROM, set whatever you've tried, and a bot opens a pull request for you with your name on the commit. If something's missing or doesn't add up, it comments on the issue saying what - just edit the issue and it'll try again. The "Report" link on each ROM row of a device page puts the device, its codename and the ROM in the title, so you can copy them across.
+The [form](https://github.com/NotAFlightRisk/android-rom-compat/issues/new?template=report-feature.yml) is the easy way. Pick a device and ROM, set whatever you've tried, and a bot opens a pull request for you with your name on the commit. If something's missing or doesn't add up, it comments on the issue saying what - just edit the issue and it'll try again. The "Report what works" button on each ROM on a device page puts the device, its codename and the ROM in the title, so you can copy them across.
Rather do it by hand? Add a `features` section to `data/support//.yml`, making the file if it's not there yet. Anything you leave out shows as unknown, which is fine.
@@ -40,7 +40,7 @@ features:
widevine: L3
```
-`android` and `build` are what you tested on, `checked` is the date, and `variant` is for a ROM's other builds (like LineageOS for microG). A report shows as stale once the ROM moves to a newer Android than yours, or `checked` is over a year old.
+`android` and `build` are what you tested on, `checked` is the date, and `variant` is for a ROM's other builds (like LineageOS for microG). Once the ROM moves to a newer Android than yours, or `checked` is over a year old, the site says when it was last checked.
The keys come from [`features.yml`](../data/features.yml). Most features use these:
diff --git a/.github/README.md b/.github/README.md
index ea73334..ccf0343 100644
--- a/.github/README.md
+++ b/.github/README.md
@@ -16,7 +16,7 @@
Covers GrapheneOS, LineageOS, CalyxOS, /e/OS, crDroid, iodéOS, Evolution X and PixelOS, official builds only. Every bit of data lives as YAML in [`data/`](../data), so fixing a mistake just means editing a file - no coding needed.
-It'd rather leave a gap than guess, so anything nobody has checked shows as unknown. A report goes stale once the ROM moves to a newer Android than it was checked on, or when it's over a year old.
+It'd rather leave a gap than guess, so anything nobody has checked is left out, and shows as unknown in the table view. A report goes stale once the ROM moves to a newer Android than it was checked on, or when it's over a year old.
---
diff --git a/package-lock.json b/package-lock.json
index d10c144..9099aeb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"@astrojs/sitemap": "^3.7.4",
"@fontsource-variable/roboto": "^5.3.0",
"@fontsource-variable/roboto-mono": "^5.3.0",
+ "@lucide/astro": "^1.52.0",
"@sentry/browser": "^11.4.0",
"astro": "^7.3.5"
},
@@ -1832,6 +1833,15 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@lucide/astro": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/@lucide/astro/-/astro-1.52.0.tgz",
+ "integrity": "sha512-Vyo3sHYPtLvP5fbvqrMSMutcRzu8tbZXG6xFylrDnpeGVjLisokqKrWKuEm6K4IKwxkTrKK/xyXkVBE0WjxZKg==",
+ "license": "ISC",
+ "peerDependencies": {
+ "astro": "^4 || ^5 || ^6 || ^7"
+ }
+ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz",
diff --git a/package.json b/package.json
index 30042ed..0240746 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"@astrojs/sitemap": "^3.7.4",
"@fontsource-variable/roboto": "^5.3.0",
"@fontsource-variable/roboto-mono": "^5.3.0",
+ "@lucide/astro": "^1.52.0",
"@sentry/browser": "^11.4.0",
"astro": "^7.3.5"
},
diff --git a/public/icons/broken.svg b/public/icons/broken.svg
index d0860b4..b50c2d2 100644
--- a/public/icons/broken.svg
+++ b/public/icons/broken.svg
@@ -1 +1 @@
-
+
diff --git a/public/icons/ended.svg b/public/icons/ended.svg
index a3a7ac1..9b99fae 100644
--- a/public/icons/ended.svg
+++ b/public/icons/ended.svg
@@ -1 +1 @@
-
+
diff --git a/public/icons/na.svg b/public/icons/na.svg
index cca95cc..66042ad 100644
--- a/public/icons/na.svg
+++ b/public/icons/na.svg
@@ -1 +1 @@
-
+
diff --git a/public/icons/note.svg b/public/icons/note.svg
index 457533c..a1bdf99 100644
--- a/public/icons/note.svg
+++ b/public/icons/note.svg
@@ -1 +1 @@
-
+
diff --git a/public/icons/partial.svg b/public/icons/partial.svg
index 0d18c89..bf115b7 100644
--- a/public/icons/partial.svg
+++ b/public/icons/partial.svg
@@ -1 +1 @@
-
+
diff --git a/public/icons/sort-down.svg b/public/icons/sort-down.svg
index 46c0ed7..2da218a 100644
--- a/public/icons/sort-down.svg
+++ b/public/icons/sort-down.svg
@@ -1 +1 @@
-
+
diff --git a/public/icons/sort-up.svg b/public/icons/sort-up.svg
index 5133e98..8df68b2 100644
--- a/public/icons/sort-up.svg
+++ b/public/icons/sort-up.svg
@@ -1 +1 @@
-
+
diff --git a/public/icons/sort.svg b/public/icons/sort.svg
index 6f0a0b1..3229b07 100644
--- a/public/icons/sort.svg
+++ b/public/icons/sort.svg
@@ -1 +1 @@
-
+
diff --git a/public/icons/stale.svg b/public/icons/stale.svg
index 08bc4db..0090270 100644
--- a/public/icons/stale.svg
+++ b/public/icons/stale.svg
@@ -1 +1 @@
-
+
diff --git a/public/icons/unknown.svg b/public/icons/unknown.svg
index 6174fa5..2ded53b 100644
--- a/public/icons/unknown.svg
+++ b/public/icons/unknown.svg
@@ -1 +1 @@
-
+
diff --git a/public/icons/working.svg b/public/icons/working.svg
index 238bb33..49b86c4 100644
--- a/public/icons/working.svg
+++ b/public/icons/working.svg
@@ -1 +1 @@
-
+
diff --git a/src/assets/logos/README.md b/src/assets/logos/README.md
new file mode 100644
index 0000000..7d6e9f9
--- /dev/null
+++ b/src/assets/logos/README.md
@@ -0,0 +1,38 @@
+# Logos
+
+Single-colour marks used to identify each brand and ROM. The site paints them as a CSS mask, so they always show in the current text colour.
+
+Brand and ROM names and logos are trademarks of their respective owners and are used here only to identify them. No endorsement is implied.
+
+| File | Source | Licence / terms |
+| --- | --- | --- |
+| `brands/asus.svg` | https://unpkg.com/simple-icons@16.34.0/icons/asus.svg | Simple Icons 16.34.0, CC0-1.0 |
+| `brands/fairphone.svg` | https://unpkg.com/simple-icons@16.34.0/icons/fairphone.svg | Simple Icons 16.34.0, CC0-1.0 |
+| `brands/google.svg` | https://unpkg.com/simple-icons@16.34.0/icons/google.svg | Simple Icons 16.34.0, CC0-1.0 |
+| `brands/htc.svg` | https://unpkg.com/simple-icons@16.34.0/icons/htc.svg | Simple Icons 16.34.0, CC0-1.0 |
+| `brands/huawei.svg` | https://unpkg.com/simple-icons@16.34.0/icons/huawei.svg | Simple Icons 16.34.0, CC0-1.0 |
+| `brands/lenovo.svg` | https://unpkg.com/simple-icons@16.34.0/icons/lenovo.svg | Simple Icons 16.34.0, CC0-1.0 |
+| `brands/lg.svg` | https://unpkg.com/simple-icons@16.34.0/icons/lg.svg | Simple Icons 16.34.0, CC0-1.0 (face symbol only, "LG" letters removed) |
+| `brands/meizu.svg` | https://unpkg.com/simple-icons@16.34.0/icons/meizu.svg | Simple Icons 16.34.0, CC0-1.0 |
+| `brands/motorola.svg` | https://unpkg.com/simple-icons@16.34.0/icons/motorola.svg | Simple Icons 16.34.0, CC0-1.0 |
+| `brands/nokia.svg` | https://unpkg.com/simple-icons@16.34.0/icons/nokia.svg | Simple Icons 16.34.0, CC0-1.0 |
+| `brands/nvidia.svg` | https://unpkg.com/simple-icons@16.34.0/icons/nvidia.svg | Simple Icons 16.34.0, CC0-1.0 |
+| `brands/oneplus.svg` | https://unpkg.com/simple-icons@16.34.0/icons/oneplus.svg | Simple Icons 16.34.0, CC0-1.0 |
+| `brands/oppo.svg` | https://unpkg.com/simple-icons@16.34.0/icons/oppo.svg | Simple Icons 16.34.0, CC0-1.0 |
+| `brands/razer.svg` | https://unpkg.com/simple-icons@16.34.0/icons/razer.svg | Simple Icons 16.34.0, CC0-1.0 |
+| `brands/samsung.svg` | https://unpkg.com/simple-icons@16.34.0/icons/samsung.svg | Simple Icons 16.34.0, CC0-1.0 |
+| `brands/solana.svg` | https://unpkg.com/simple-icons@16.34.0/icons/solana.svg | Simple Icons 16.34.0, CC0-1.0 |
+| `brands/sony.svg` | https://unpkg.com/simple-icons@16.34.0/icons/sony.svg | Simple Icons 16.34.0, CC0-1.0 |
+| `brands/xiaomi.svg` | https://unpkg.com/simple-icons@16.34.0/icons/xiaomi.svg | Simple Icons 16.34.0, CC0-1.0 |
+| `roms/lineageos.svg` | https://unpkg.com/simple-icons@16.34.0/icons/lineageos.svg | Simple Icons 16.34.0, CC0-1.0 |
+| `roms/grapheneos.svg` | https://unpkg.com/simple-icons@16.34.0/icons/grapheneos.svg | Simple Icons 16.34.0, CC0-1.0 |
+| `roms/eos.svg` | https://unpkg.com/simple-icons@16.34.0/icons/e.svg | Simple Icons 16.34.0, CC0-1.0 |
+| `roms/calyxos.svg` | https://gitlab.com/CalyxOS/branding/-/raw/main/calyxos/icon/Icon-CalyxOS.light.svg | Registered trademark of the Calyx Institute (background square removed) |
+| `roms/crdroid.svg` | https://raw.githubusercontent.com/crdroidandroid/android_packages_apps_crDroidSettings/17.0/res/drawable/ic_crdroid_logo.xml | Trademark of the crDroid Android Project; file header Apache-2.0 |
+| `roms/iodeos.svg` | https://gitlab.com/iode/os/public_archive/blocker/iode/-/raw/main/android/app/src/main/res/drawable/ic_iode_foreground.xml | Trademark of iodé Technologies; file header AGPL-3.0-or-later |
+| `roms/evolutionx.svg` | https://raw.githubusercontent.com/Evolution-X/wiki/main/public/icon.svg | Trademark of Evolution X |
+| `roms/pixelos.svg` | https://pixelos.net/images/logo.svg | Trademark of PixelOS |
+
+Simple Icons releases its icon data under CC0-1.0, but its disclaimer notes that the logos themselves remain trademarks of their owners: https://github.com/simple-icons/simple-icons/blob/develop/DISCLAIMER.md
+
+To add a logo, drop a single-colour SVG named after the brand or ROM key into `brands/` or `roms/`; anything without one shows its initials.
diff --git a/src/assets/logos/brands/asus.svg b/src/assets/logos/brands/asus.svg
new file mode 100644
index 0000000..fece3c0
--- /dev/null
+++ b/src/assets/logos/brands/asus.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/logos/brands/fairphone.svg b/src/assets/logos/brands/fairphone.svg
new file mode 100644
index 0000000..8738949
--- /dev/null
+++ b/src/assets/logos/brands/fairphone.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/logos/brands/google.svg b/src/assets/logos/brands/google.svg
new file mode 100644
index 0000000..70d0318
--- /dev/null
+++ b/src/assets/logos/brands/google.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/logos/brands/htc.svg b/src/assets/logos/brands/htc.svg
new file mode 100644
index 0000000..70d11b3
--- /dev/null
+++ b/src/assets/logos/brands/htc.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/logos/brands/huawei.svg b/src/assets/logos/brands/huawei.svg
new file mode 100644
index 0000000..72e9610
--- /dev/null
+++ b/src/assets/logos/brands/huawei.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/logos/brands/lenovo.svg b/src/assets/logos/brands/lenovo.svg
new file mode 100644
index 0000000..53f569f
--- /dev/null
+++ b/src/assets/logos/brands/lenovo.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/logos/brands/lg.svg b/src/assets/logos/brands/lg.svg
new file mode 100644
index 0000000..f032f8a
--- /dev/null
+++ b/src/assets/logos/brands/lg.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/logos/brands/meizu.svg b/src/assets/logos/brands/meizu.svg
new file mode 100644
index 0000000..e9b287b
--- /dev/null
+++ b/src/assets/logos/brands/meizu.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/logos/brands/motorola.svg b/src/assets/logos/brands/motorola.svg
new file mode 100644
index 0000000..fd59645
--- /dev/null
+++ b/src/assets/logos/brands/motorola.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/logos/brands/nokia.svg b/src/assets/logos/brands/nokia.svg
new file mode 100644
index 0000000..9279606
--- /dev/null
+++ b/src/assets/logos/brands/nokia.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/logos/brands/nvidia.svg b/src/assets/logos/brands/nvidia.svg
new file mode 100644
index 0000000..a9c8fc4
--- /dev/null
+++ b/src/assets/logos/brands/nvidia.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/logos/brands/oneplus.svg b/src/assets/logos/brands/oneplus.svg
new file mode 100644
index 0000000..4f75925
--- /dev/null
+++ b/src/assets/logos/brands/oneplus.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/logos/brands/oppo.svg b/src/assets/logos/brands/oppo.svg
new file mode 100644
index 0000000..7744171
--- /dev/null
+++ b/src/assets/logos/brands/oppo.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/logos/brands/razer.svg b/src/assets/logos/brands/razer.svg
new file mode 100644
index 0000000..bf97c1e
--- /dev/null
+++ b/src/assets/logos/brands/razer.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/logos/brands/samsung.svg b/src/assets/logos/brands/samsung.svg
new file mode 100644
index 0000000..6f6e1ae
--- /dev/null
+++ b/src/assets/logos/brands/samsung.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/logos/brands/solana.svg b/src/assets/logos/brands/solana.svg
new file mode 100644
index 0000000..9b8119f
--- /dev/null
+++ b/src/assets/logos/brands/solana.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/logos/brands/sony.svg b/src/assets/logos/brands/sony.svg
new file mode 100644
index 0000000..cec8b2c
--- /dev/null
+++ b/src/assets/logos/brands/sony.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/logos/brands/xiaomi.svg b/src/assets/logos/brands/xiaomi.svg
new file mode 100644
index 0000000..fb4cd3d
--- /dev/null
+++ b/src/assets/logos/brands/xiaomi.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/logos/roms/calyxos.svg b/src/assets/logos/roms/calyxos.svg
new file mode 100644
index 0000000..b88831b
--- /dev/null
+++ b/src/assets/logos/roms/calyxos.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/logos/roms/crdroid.svg b/src/assets/logos/roms/crdroid.svg
new file mode 100644
index 0000000..061b7e3
--- /dev/null
+++ b/src/assets/logos/roms/crdroid.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/logos/roms/eos.svg b/src/assets/logos/roms/eos.svg
new file mode 100644
index 0000000..c36a8f3
--- /dev/null
+++ b/src/assets/logos/roms/eos.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/logos/roms/evolutionx.svg b/src/assets/logos/roms/evolutionx.svg
new file mode 100644
index 0000000..cef38ed
--- /dev/null
+++ b/src/assets/logos/roms/evolutionx.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/logos/roms/grapheneos.svg b/src/assets/logos/roms/grapheneos.svg
new file mode 100644
index 0000000..3201907
--- /dev/null
+++ b/src/assets/logos/roms/grapheneos.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/logos/roms/iodeos.svg b/src/assets/logos/roms/iodeos.svg
new file mode 100644
index 0000000..012aa67
--- /dev/null
+++ b/src/assets/logos/roms/iodeos.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/logos/roms/lineageos.svg b/src/assets/logos/roms/lineageos.svg
new file mode 100644
index 0000000..1984577
--- /dev/null
+++ b/src/assets/logos/roms/lineageos.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/logos/roms/pixelos.svg b/src/assets/logos/roms/pixelos.svg
new file mode 100644
index 0000000..2d49b22
--- /dev/null
+++ b/src/assets/logos/roms/pixelos.svg
@@ -0,0 +1 @@
+
diff --git a/src/components/cards/CardGrid.astro b/src/components/cards/CardGrid.astro
new file mode 100644
index 0000000..887895a
--- /dev/null
+++ b/src/components/cards/CardGrid.astro
@@ -0,0 +1,143 @@
+---
+// Device cards in optional brand groups, from plain items built in src/lib/cards.js
+import Logo from '../ui/Logo.astro';
+
+const { id, groups, level = 2 } = Astro.props;
+const Heading = `h${level}`;
+---
+
+
+ {groups.map(({ brand, items }) => (
+
+ {brand && (
+
+
+ {brand.name}
+
+ )}
+
+ {items.map((item) => (
+
+
+ {item.title}
+
+ {item.code}
+ {item.meta && {item.meta}
}
+ {item.status && (
+
+ {item.status.text}
+
+ )}
+ {item.chips.length > 0 && (
+
+ {item.chips.map((chip) => (
+
+ {chip.note ? `${chip.text}, ${chip.note}` : chip.text}
+
+ ))}
+
+ )}
+ {item.empty && (
+
+ {item.empty}
+
+ )}
+
+ ))}
+
+
+ ))}
+
+
+
diff --git a/src/components/cards/FeatureList.astro b/src/components/cards/FeatureList.astro
new file mode 100644
index 0000000..81adb3d
--- /dev/null
+++ b/src/components/cards/FeatureList.astro
@@ -0,0 +1,70 @@
+---
+// What works, one feature per line, from knownFeatures() in src/lib/facts.js
+import Icon from '../ui/Icon.astro';
+
+const { items } = Astro.props;
+---
+
+
+
+
diff --git a/src/components/cards/RomCard.astro b/src/components/cards/RomCard.astro
new file mode 100644
index 0000000..8826a60
--- /dev/null
+++ b/src/components/cards/RomCard.astro
@@ -0,0 +1,156 @@
+---
+// One ROM on one device: its build, what it needs, what works, and where to install it from
+import FeatureList from './FeatureList.astro';
+import Icon from '../ui/Icon.astro';
+import Logo from '../ui/Logo.astro';
+import { buildFact, googleApps, knownFeatures, needsOf, relockFact } from '../../lib/facts.js';
+import { formatDate } from '../../lib/dates.js';
+import { reportUrl } from '../../lib/table.js';
+
+const { row, features } = Astro.props;
+const { rom, device } = row;
+const build = buildFact(row.latest);
+const relock = relockFact(row);
+const needs = needsOf(row);
+const known = knownFeatures(features, row.cells);
+const builds = row.variants.filter((variant) => variant.status === 'active');
+const id = `rom-${rom.key}`;
+---
+
+
+
+
+
+ {row.android &&
Android {row.android} }
+
+
+
+ {build && (
+
+ Built {build.text}
+ {build.old && · {build.old} }
+
+ )}
+ {build?.patch && {build.patch} security patch }
+ {relock && (
+
+
+ {relock.text}
+
+ {relock.note && {relock.note} }
+
+ )}
+ {needs.map((need) => (
+ {need}
+ ))}
+
+
+ {known.length > 0 && }
+
+ {builds.map((variant) => (
+
+ Also as {variant.name}
+ {variant.latest && <>, built {formatDate(variant.latest.date)}>}
+
+ ))}
+
+
+
+
+
diff --git a/src/components/cards/RomSummary.astro b/src/components/cards/RomSummary.astro
new file mode 100644
index 0000000..bd52d57
--- /dev/null
+++ b/src/components/cards/RomSummary.astro
@@ -0,0 +1,74 @@
+---
+// A ROM in brief: what it is, its take on Google apps and security, and how many devices it runs on
+import Logo from '../ui/Logo.astro';
+import { googleApps, romRelock } from '../../lib/facts.js';
+import { labelOf } from '../../lib/labels.js';
+import { plural } from '../../lib/text.js';
+
+const { rom, level = 3 } = Astro.props;
+const Heading = `h${level}`;
+const relock = romRelock(rom);
+const badges = [
+ googleApps(rom),
+ relock !== 'No' && (relock === 'Yes' ? 'Relockable bootloader' : 'Relocks on some devices'),
+ ...rom.focus.map(labelOf),
+].filter(Boolean);
+---
+
+
+
+ {rom.description}
+
+ {badges.map((badge) => (
+ {badge}
+ ))}
+
+
+
+
diff --git a/src/components/layout/Base.astro b/src/components/layout/Base.astro
index b2a6955..545a176 100644
--- a/src/components/layout/Base.astro
+++ b/src/components/layout/Base.astro
@@ -1,7 +1,9 @@
---
import '@fontsource-variable/roboto';
import '@fontsource-variable/roboto-mono';
-import '../../styles/global.css';
+import '../../styles/tokens.css';
+import '../../styles/base.css';
+import '../../styles/ui.css';
import roboto from '@fontsource-variable/roboto/files/roboto-latin-wght-normal.woff2?url';
import Header from './Header.astro';
import Footer from './Footer.astro';
@@ -70,13 +72,9 @@ const sentry = import.meta.env.PUBLIC_SENTRY_DSN;
-
-
-
+
{graph.length > 0 && (
+
+
diff --git a/src/components/ui/Icon.astro b/src/components/ui/Icon.astro
index 7f7fcaa..30ab3c7 100644
--- a/src/components/ui/Icon.astro
+++ b/src/components/ui/Icon.astro
@@ -1,25 +1,40 @@
---
-const { name, label } = Astro.props;
-const paths = {
- search: 'M10.5 17a6.5 6.5 0 1 0 0-13 6.5 6.5 0 0 0 0 13zM15.5 15.5L20 20',
- columns: 'M4 5h16v14H4zM9.5 5v14M14.5 5v14',
- external: 'M14 5h5v5M19 5l-8 8M17 14v5H5V7h5',
- edit: 'M4 20h4L19 9l-4-4L4 16v4zM13.5 6.5l4 4',
+// The Lucide icons the site uses, by name. Each renders as inline SVG at build time, text-sized
+import ArrowUpRight from '@lucide/astro/icons/arrow-up-right';
+import BookOpen from '@lucide/astro/icons/book-open';
+import CodeXml from '@lucide/astro/icons/code-xml';
+import Columns3 from '@lucide/astro/icons/columns-3';
+import Flag from '@lucide/astro/icons/flag';
+import Globe from '@lucide/astro/icons/globe';
+import LayoutGrid from '@lucide/astro/icons/layout-grid';
+import MessagesSquare from '@lucide/astro/icons/messages-square';
+import Pencil from '@lucide/astro/icons/pencil';
+import Search from '@lucide/astro/icons/search';
+import Table2 from '@lucide/astro/icons/table-2';
+
+const icons = {
+ cards: LayoutGrid,
+ columns: Columns3,
+ community: MessagesSquare,
+ docs: BookOpen,
+ edit: Pencil,
+ external: ArrowUpRight,
+ install: BookOpen,
+ report: Flag,
+ search: Search,
+ source: CodeXml,
+ table: Table2,
+ website: Globe,
};
+
+const { name, label } = Astro.props;
+const Glyph = icons[name];
+if (!Glyph) throw new Error(`There's no icon called "${name}" in Icon.astro`);
---
-
-
-
+/>
diff --git a/src/components/ui/Logo.astro b/src/components/ui/Logo.astro
new file mode 100644
index 0000000..ce63bd0
--- /dev/null
+++ b/src/components/ui/Logo.astro
@@ -0,0 +1,76 @@
+---
+// A brand or ROM mark on a plate, or its initials when src/assets/logos/ has no file for it
+const marks = import.meta.glob('../../assets/logos/*/*.svg', {
+ query: '?raw',
+ import: 'default',
+ eager: true,
+});
+const urls = import.meta.glob('../../assets/logos/*/*.svg', {
+ query: '?url',
+ import: 'default',
+ eager: true,
+});
+
+const { kind, item, size = 'md' } = Astro.props;
+const file = `../../assets/logos/${kind}/${item.key}.svg`;
+const [, , width, height] =
+ marks[file]
+ ?.match(/viewBox="([^"]+)"/)[1]
+ .split(' ')
+ .map(Number) ?? [];
+const initials = item.name.length <= 3 ? item.name : item.name.match(/[a-z0-9]/i)[0].toUpperCase();
+---
+
+
+ {urls[file] ? (
+
+ ) : (
+ {initials}
+ )}
+
+
+
diff --git a/src/components/ui/RomFeatures.astro b/src/components/ui/RomFeatures.astro
deleted file mode 100644
index a167737..0000000
--- a/src/components/ui/RomFeatures.astro
+++ /dev/null
@@ -1,58 +0,0 @@
----
-import { labelOf } from '../../lib/labels.js';
-
-const { features, facts } = Astro.props;
-const known = features.filter(({ key }) => facts[key]);
----
-
-
- {known.map(({ key, name }) => {
- const { status, note, inferred, source } = facts[key];
- return (
-
-
{name}
-
- {labelOf(status)}
- {inferred && inferred }
- {note && {note}
}
- Source
-
-
- );
- })}
-
-
-
diff --git a/src/components/ui/ViewSwitch.astro b/src/components/ui/ViewSwitch.astro
new file mode 100644
index 0000000..a166cd4
--- /dev/null
+++ b/src/components/ui/ViewSwitch.astro
@@ -0,0 +1,107 @@
+---
+// Cards or Table, as two radios so it works without JS. The script runs before the views are
+// parsed, so a saved choice never flashes the other one first
+import Icon from './Icon.astro';
+---
+
+
+ View as
+
+
+ Cards
+
+
+
+ Table
+
+
+
+
+
+
diff --git a/src/lib/cards.js b/src/lib/cards.js
new file mode 100644
index 0000000..815a68e
--- /dev/null
+++ b/src/lib/cards.js
@@ -0,0 +1,50 @@
+import { buildFact, lockTone, unlockFact } from './facts.js';
+import { filterData, romStatus } from './table.js';
+
+/** Splits items into one group per brand, in brand order, for a CardGrid */
+export const byBrand = (list, brandOf, toItem) =>
+ [...Map.groupBy(list, brandOf)]
+ .sort(([a], [b]) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }))
+ .map(([brand, items]) => ({ brand, items: items.map(toItem) }));
+
+/** A card's meta line, e.g. "Android 16 · Built 2 Oct 2026", skipping what's missing */
+const metaLine = (...parts) => parts.filter(Boolean).join(' · ');
+
+const unlockStatus = (device) => {
+ const { unlock } = device.bootloader;
+ return unlock === 'unknown'
+ ? undefined
+ : { tone: lockTone[unlock], text: unlockFact(device).text };
+};
+
+/** A device on the device lists: its bootloader and the ROMs that still build for it */
+export const deviceCard = (device, roms, showUnlock = true) => ({
+ key: device.key,
+ href: device.url,
+ title: device.name,
+ code: device.codenames[0],
+ meta: metaLine(device.released),
+ status: showUnlock ? unlockStatus(device) : undefined,
+ chips: roms.flatMap((rom) => {
+ const { tone, text } = romStatus(device, rom);
+ if (tone === 'working') return [{ text: rom.name }];
+ return tone === 'partial' ? [{ text: rom.name, tone, note: text.toLowerCase() }] : [];
+ }),
+ empty: device.activeCount === 0 ? 'Support has ended' : undefined,
+ data: filterData(device),
+});
+
+/** A device on a ROM's page: which Android it gets and how fresh the build is */
+export const romDeviceCard = (row) => {
+ const build = buildFact(row.latest);
+ return {
+ key: row.device.key,
+ href: row.device.url,
+ title: row.device.name,
+ code: row.device.codenames[0],
+ meta: metaLine(row.android && `Android ${row.android}`, build && `Built ${build.text}`),
+ status: build?.old && { tone: 'partial', text: build.old },
+ chips: [],
+ data: filterData(row.device, false),
+ };
+};
diff --git a/src/lib/data/model.js b/src/lib/data/model.js
index 64e8775..8adfbd5 100644
--- a/src/lib/data/model.js
+++ b/src/lib/data/model.js
@@ -112,8 +112,10 @@ export function buildModel(data = getData(), now = new Date()) {
.sort()
.at(-1);
}
- for (const rom of roms.values())
+ for (const rom of roms.values()) {
rom.support.sort((a, b) => a.device.title.localeCompare(b.device.title));
+ rom.activeCount = rom.support.filter((row) => row.active).length;
+ }
for (const brand of brands.values()) brand.devices.sort(byName('title'));
return {
diff --git a/src/lib/dates.js b/src/lib/dates.js
index 4f52bd3..8dd9d69 100644
--- a/src/lib/dates.js
+++ b/src/lib/dates.js
@@ -2,3 +2,18 @@
export const isoDate = (value = Date.now()) => new Date(value).toISOString().slice(0, 10);
export const today = isoDate();
+
+const MONTHS = 'Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'.split(' ');
+const parts = (date) => date.split('-').map(Number);
+
+/** "2026-10-02" as "2 Oct 2026" */
+export const formatDate = (date) => {
+ const [year, month, day] = parts(date);
+ return `${day} ${MONTHS[month - 1]} ${year}`;
+};
+
+/** "2032-04-01" as "Apr 2032" */
+export const formatMonth = (date) => {
+ const [year, month] = parts(date);
+ return `${MONTHS[month - 1]} ${year}`;
+};
diff --git a/src/lib/facts.js b/src/lib/facts.js
new file mode 100644
index 0000000..6238f52
--- /dev/null
+++ b/src/lib/facts.js
@@ -0,0 +1,167 @@
+import { labelOf, yesNo } from './labels.js';
+import { formatDate, formatMonth, today } from './dates.js';
+import { isLink } from './text.js';
+import { resolveCell } from './data/status.js';
+
+/** Builds read as fresh under two months old, ageing up to six, and old after that */
+export const ageTone = (date, now = Date.now()) => {
+ const days = (now - Date.parse(date)) / 864e5;
+ if (days < 60) return 'working';
+ return days < 180 ? 'partial' : 'broken';
+};
+
+export const lockTone = {
+ yes: 'working',
+ conditional: 'partial',
+ no: 'broken',
+ unknown: 'unknown',
+};
+
+const unlockWords = {
+ yes: 'Unlocks',
+ conditional: 'Unlocks, with conditions',
+ no: "Doesn't unlock",
+ unknown: 'Unknown',
+};
+
+/** The bootloader answer, with the device's own note, or the brand policy it shares */
+export function unlockFact({ bootloader, brand }) {
+ const own = bootloader.notes || bootloader.source ? bootloader : undefined;
+ const policy = brand.bootloader?.unlock === bootloader.unlock ? brand.bootloader : undefined;
+ const { notes, source } = own ?? policy ?? {};
+ return {
+ tone: lockTone[bootloader.unlock],
+ text: unlockWords[bootloader.unlock],
+ note: notes,
+ source,
+ };
+}
+
+/** Official updates from the manufacturer, e.g. "Security updates until Apr 2032" */
+export function stockFact(stock, now = today) {
+ if (!stock) return undefined;
+ const { android, security_until, updates_until, source } = stock;
+ const until = security_until ?? updates_until;
+ const note = android && `Android ${android.replace(' - ', ' to ')}`;
+ if (!until) return note && { text: note, source };
+ if (until === 'ended' || until < now) {
+ return { tone: 'ended', text: 'Updates have ended', note, source };
+ }
+ const kind = security_until ? 'Security updates' : 'Updates';
+ return { tone: 'working', text: `${kind} until ${formatMonth(until)}`, note, source };
+}
+
+/** The newest build, worded so its age reads without the colour */
+export function buildFact(latest, now) {
+ if (!latest) return undefined;
+ const { date, version, patch } = latest;
+ const tone = ageTone(date, now);
+ return {
+ date,
+ tone,
+ text: formatDate(date),
+ old: tone === 'broken' ? 'Over 6 months old' : undefined,
+ version,
+ patch: patch && formatMonth(patch),
+ };
+}
+
+export const relockOf = ({ relock }) =>
+ typeof relock === 'string' ? { status: relock } : (relock ?? { status: 'unknown' });
+
+/** Whether a ROM relocks the bootloader, allowing for devices whose own page says otherwise */
+export const romRelock = (rom) => {
+ const relockable = rom.security.relockable_bootloader;
+ const differs = rom.support.some(
+ (row) => row.active && relockOf(row).status === (relockable ? 'no' : 'yes'),
+ );
+ return differs ? 'On some devices' : yesNo(relockable);
+};
+
+const relockWords = { yes: 'Bootloader relocks', no: "Can't relock the bootloader" };
+
+/** Only says something when the ROM's device page does */
+export function relockFact(row) {
+ const { status, note } = relockOf(row);
+ return relockWords[status] && { tone: lockTone[status], text: relockWords[status], note };
+}
+
+/** What has to happen before installing, e.g. "Stock Android 16 first" or "Beta build" */
+export const needsOf = ({ firmware, channel }) =>
+ [
+ firmware && (/^\d/.test(firmware) ? `Stock Android ${firmware} first` : firmware),
+ channel && channel !== 'stable' && `${labelOf(channel)} build`,
+ ].filter(Boolean);
+
+const googleWords = {
+ none: 'No Google apps',
+ microg: 'microG instead of Google',
+ sandboxed: 'Sandboxed Google Play',
+ optional: 'Add Google apps yourself',
+ bundled: 'Google apps built in',
+};
+
+export const googleApps = (build) => googleWords[build.google];
+
+const statusWords = {
+ working: 'Works',
+ partial: 'Partly works',
+ broken: "Doesn't work",
+ unknown: 'Unknown',
+ 'n/a': 'N/A',
+};
+
+/** Lower-cases a leading capital, but leaves acronyms like L1 alone */
+const midSentence = (text) =>
+ /^[A-Z][a-z]/.test(text) ? text[0].toLowerCase() + text.slice(1) : text;
+
+/** A cell in words, e.g. "Works", "Basic" or "Likely doesn't work" */
+export const statusText = ({ value, inferred }) => {
+ const text = statusWords[value] ?? labelOf(value);
+ return inferred ? `Likely ${midSentence(text)}` : text;
+};
+
+/** When and on what a report was made, e.g. "Reported 17 Sep 2026 on Android 16" */
+export const reportContext = ({ origin, checked, android, build, variant }) => {
+ if (origin !== 'report' || !(checked || android || build || variant)) return undefined;
+ const when = ['Reported', checked && formatDate(checked), android && `on Android ${android}`];
+ return [
+ when.filter(Boolean).join(' '),
+ build && `build ${build}`,
+ variant && `${labelOf(variant)} build`,
+ ]
+ .filter(Boolean)
+ .join(', ');
+};
+
+/** A report the ROM has moved on from, e.g. "Last checked Jan 2026 on Android 15" */
+export const staleText = ({ stale, android, checked }) =>
+ stale
+ ? ['Last checked', checked && formatMonth(checked), android && `on Android ${android}`]
+ .filter(Boolean)
+ .join(' ')
+ : undefined;
+
+/** Features with a real answer, in features.yml order. Unknown and missing hardware are left out */
+export const knownFeatures = (features, cells) =>
+ features.flatMap(({ key, name }) => {
+ const cell = cells[key];
+ if (!cell || ['unknown', 'n/a'].includes(cell.value)) return [];
+ return [
+ {
+ key,
+ name,
+ tone: cell.tone,
+ text: statusText(cell),
+ note: [reportContext(cell), cell.note].filter(Boolean).join(': ') || undefined,
+ stale: staleText(cell),
+ source: isLink(cell.source) ? cell.source : undefined,
+ },
+ ];
+ });
+
+/** What a ROM, or one of its builds, says holds on every device */
+export const everywhereCells = (features, build) =>
+ Object.fromEntries(
+ features.map((feature) => [feature.key, resolveCell(feature, { device: {}, rom: build })]),
+ );
diff --git a/src/lib/table.js b/src/lib/table.js
index acba40a..1a079d0 100644
--- a/src/lib/table.js
+++ b/src/lib/table.js
@@ -1,6 +1,18 @@
import { labelOf, yesNo } from './labels.js';
import { issueUrl } from './site.js';
import { isLink } from './text.js';
+import { formatMonth } from './dates.js';
+import {
+ ageTone,
+ buildFact,
+ lockTone,
+ needsOf,
+ relockOf,
+ romRelock,
+ reportContext,
+ staleText,
+ statusText,
+} from './facts.js';
const text = (value) => ({ text: value });
@@ -26,87 +38,55 @@ export const romCells = (rom) => ({
app_store: text(labelOf(rom.app_store)),
focus: text(rom.focus.map(labelOf).join(', ')),
org: text(labelOf(rom.org)),
- relockable: text(yesNo(rom.security.relockable_bootloader)),
+ relockable: text(romRelock(rom)),
verified_boot: text(yesNo(rom.security.verified_boot)),
patches: text(labelOf(rom.security.patches)),
install: text(rom.install.map(labelOf).join(', ')),
root: text(yesNo(rom.root)),
});
-/** Fresh builds read as working, anything past six months as broken */
-export const ageTone = (date) => {
- const days = (Date.now() - Date.parse(date)) / 864e5;
- if (days < 60) return 'working';
- return days < 180 ? 'partial' : 'broken';
-};
-
export const latestCell = ({ latest }, detailed = false) => {
- if (!latest) return {};
- const { version, date, patch } = latest;
- const note = [version && `Version ${version}`, patch && `security patch ${patch}`]
+ const build = buildFact(latest);
+ if (!build) return {};
+ const note = [build.version && `Version ${build.version}`, build.patch && `${build.patch} patch`]
.filter(Boolean)
.join(', ');
- return { date, tone: ageTone(date), note: (detailed && note) || undefined };
+ return {
+ date: build.date,
+ text: build.text,
+ tone: build.tone,
+ note: (detailed && note) || undefined,
+ };
};
-const lockTone = { yes: 'working', conditional: 'partial', no: 'broken', unknown: 'unknown' };
-
-export const relockCell = ({ relock }) => {
- if (!relock) return {};
- const { status, note, origin } = typeof relock === 'string' ? { status: relock } : relock;
- return { tone: lockTone[status], text: labelOf(status), suffix: originSuffix({ origin }), note };
+export const relockCell = (row) => {
+ if (!row.relock) return {};
+ const { status, note } = relockOf(row);
+ return { tone: lockTone[status], text: labelOf(status), note };
};
-export const needsCell = ({ firmware, channel }) => {
- const chips = [
- firmware && (/^\d/.test(firmware) ? `Stock Android ${firmware} first` : firmware),
- channel && channel !== 'stable' && labelOf(channel),
- ].filter(Boolean);
+export const needsCell = (row) => {
+ const chips = needsOf(row);
return chips.length ? { chips } : {};
};
-const suffixes = { upstream: 'docs', rom: 'ROM', brand: 'brand' };
-
-export const originSuffix = ({ origin, inferred }) =>
- [suffixes[origin], inferred && 'inferred'].filter(Boolean).join(', ') || undefined;
-
-/** Where a cell's answer came from, in words, e.g. "Reported 2026-09-17 on Android 16" */
-const originLead = (rom, { origin, inferred, checked, android, build, variant }) => {
- const leads = {
- report: [
- ['Reported', checked, android && `on Android ${android}`].filter(Boolean).join(' '),
- build && `build ${build}`,
- variant && `on the ${labelOf(variant)} build`,
- ],
- upstream: [`${rom.name} docs`],
- rom: [`${rom.name}, every device`],
- };
- return leads[origin] && [...leads[origin], inferred && 'inferred'].filter(Boolean).join(', ');
-};
-
-const featureCell = (row, cell, detailed) => {
- const lead = detailed && originLead(row.rom, cell);
- return {
- tone: cell.tone,
- text: labelOf(cell.value),
- suffix: originSuffix(cell),
- stale: cell.stale,
- note: [lead, cell.note].filter(Boolean).join(': ') || undefined,
- source: lead && isLink(cell.source) ? cell.source : undefined,
- };
-};
+const featureCell = (cell, detailed) => ({
+ tone: cell.tone,
+ text: statusText(cell),
+ stale: staleText(cell),
+ note: [reportContext(cell), cell.note].filter(Boolean).join(': ') || undefined,
+ source: detailed && isLink(cell.source) ? cell.source : undefined,
+});
/** Build facts and one status cell per feature, for a device and ROM pair */
export const supportCells = (row, features, detailed = false) => ({
android: { text: row.android ?? '?', code: true },
latest: latestCell(row, detailed),
version: text(row.latest?.version),
- patch: text(row.latest?.patch),
+ patch: row.latest?.patch ? { date: row.latest.patch, text: formatMonth(row.latest.patch) } : {},
relock: relockCell(row),
needs: needsCell(row),
- ...Object.fromEntries(
- features.map(({ key }) => [key, featureCell(row, row.cells[key], detailed)]),
- ),
+ ...Object.fromEntries(features.map(({ key }) => [key, featureCell(row.cells[key], detailed)])),
});
const buildColumn = (key, label, hidden = false) => ({ key, label, group: 'build', hidden });
@@ -149,6 +129,18 @@ export const reportUrl = (device, rom) => {
export const deviceSearchText = (device) =>
[device.title, ...device.codenames, ...(device.aliases ?? [])].join(' ').toLowerCase();
+/** What FilterBar matches on, spread onto a device's card and its table row alike */
+export const filterData = (device, ended = device.activeCount === 0) => ({
+ 'data-search': deviceSearchText(device),
+ 'data-brand': device.brand.key,
+ 'data-rom': device.support
+ .filter((row) => row.active)
+ .map((row) => row.rom.key)
+ .join(' '),
+ 'data-unlock': device.bootloader.unlock,
+ 'data-ended': ended ? '' : undefined,
+});
+
export const splitByActive = (rows) => [
rows.filter((row) => row.active),
rows.filter((row) => !row.active),
@@ -161,7 +153,8 @@ export const deviceColumns = (roms) => [
...roms.map((rom) => ({ key: rom.key, label: rom.name })),
];
-const romStatus = (device, rom) => {
+/** Whether a ROM supports a device, for its column on device lists and its chip on cards */
+export const romStatus = (device, rom) => {
const row = device.support.find((entry) => entry.rom === rom);
if (!row) return { tone: 'n/a', text: 'No' };
if (!row.active) return { tone: 'ended', text: 'Ended' };
@@ -173,18 +166,12 @@ const romStatus = (device, rom) => {
export const deviceRows = (devices, roms) =>
devices.map((device) => ({
key: device.key,
- brand: device.brand.key,
- ended: device.activeCount === 0,
- search: deviceSearchText(device),
+ data: filterData(device),
head: { label: device.title, href: device.url, code: device.codenames.join(', ') },
cells: {
brand: text(device.brand.name),
released: { text: device.released ?? '?' },
- unlock: {
- tone: lockTone[device.bootloader.unlock],
- text: labelOf(device.bootloader.unlock),
- suffix: originSuffix(device.bootloader),
- },
+ unlock: { tone: lockTone[device.bootloader.unlock], text: labelOf(device.bootloader.unlock) },
...Object.fromEntries(roms.map((rom) => [rom.key, romStatus(device, rom)])),
},
}));
diff --git a/src/lib/text.js b/src/lib/text.js
index d6f3884..8b1cc6c 100644
--- a/src/lib/text.js
+++ b/src/lib/text.js
@@ -14,3 +14,5 @@ export const slugify = (text) =>
export const listOf = (items, joiner = 'or') =>
items.length > 1 ? `${items.slice(0, -1).join(', ')} ${joiner} ${items.at(-1)}` : `${items[0]}`;
+
+export const plural = (count, noun) => `${count} ${noun}${count === 1 ? '' : 's'}`;
diff --git a/src/pages/about.astro b/src/pages/about.astro
index 04bd8ad..ceb942d 100644
--- a/src/pages/about.astro
+++ b/src/pages/about.astro
@@ -31,12 +31,12 @@ const endpoints = [
Device lists and build dates come from each ROM's own published list, refreshed every week.
What works comes from reports, a ROM's docs for that device, or what the ROM says holds on
- every device, and each cell says which. Anything worked out rather than stated outright is
- marked inferred.
+ every device, and answers link to their source where there is one. Anything worked out rather
+ than stated outright reads as likely, like "Likely doesn't work".
- A report goes stale once the ROM moves to a newer Android than it was checked on, or when
- it's over a year old.
+ Once the ROM moves to a newer Android than a report was checked on, or the report is over a
+ year old, it says when it was last checked.
diff --git a/src/pages/api/[name].json.js b/src/pages/api/[name].json.js
index 413a48a..96c3421 100644
--- a/src/pages/api/[name].json.js
+++ b/src/pages/api/[name].json.js
@@ -14,7 +14,7 @@ const dumps = {
...strip(device),
brand: brand.key,
})),
- roms: ({ roms }) => roms.map(strip),
+ roms: ({ roms }) => roms.map(({ activeCount, ...rom }) => strip(rom)),
support: ({ support }) =>
support.map(({ file, device, rom, cells, variants, active, ...row }) => ({
device: device.key,
diff --git a/src/pages/devices/[brand]/[slug].astro b/src/pages/devices/[brand]/[slug].astro
index b090ece..b89fd4b 100644
--- a/src/pages/devices/[brand]/[slug].astro
+++ b/src/pages/devices/[brand]/[slug].astro
@@ -1,7 +1,10 @@
---
import Base from '../../../components/layout/Base.astro';
+import PageHead from '../../../components/layout/PageHead.astro';
+import RomCard from '../../../components/cards/RomCard.astro';
import Matrix from '../../../components/table/Matrix.astro';
import Facts from '../../../components/ui/Facts.astro';
+import Logo from '../../../components/ui/Logo.astro';
import { getModel } from '../../../lib/data/model.js';
import {
featuresFrom,
@@ -13,9 +16,10 @@ import {
supportColumns,
usedColumns,
} from '../../../lib/table.js';
+import { buildFact, stockFact, unlockFact } from '../../../lib/facts.js';
+import { formatDate } from '../../../lib/dates.js';
import { labelOf } from '../../../lib/labels.js';
import { listOf } from '../../../lib/text.js';
-import { today } from '../../../lib/dates.js';
export const getStaticPaths = () =>
getModel().devices.map((device) => ({
@@ -25,19 +29,19 @@ export const getStaticPaths = () =>
const { device } = Astro.props;
const { features } = getModel();
-const { bootloader, stock, support } = device;
-const policy = device.brand.bootloader;
-const unlock = `${labelOf(bootloader.unlock)}${bootloader.origin ? ', brand policy' : ''}`;
+const { stock, support } = device;
const [current, ended] = splitByActive(support);
-const unreported = featuresFrom(features, support, 'none');
+const unlock = unlockFact(device);
+const updates = stockFact(stock);
const quiet = new Set(
- [...unreported, ...featuresFrom(features, support, 'hardware')].map(({ key }) => key),
+ [...featuresFrom(features, support, 'none'), ...featuresFrom(features, support, 'hardware')].map(
+ ({ key }) => key,
+ ),
);
const rowsOf = (list) =>
list.map((row) => ({
key: row.rom.key,
- search: row.rom.name.toLowerCase(),
head: {
label: row.rom.name,
href: row.rom.url,
@@ -45,10 +49,14 @@ const rowsOf = (list) =>
...(row.install ? [{ label: 'Install guide', href: row.install }] : []),
{ label: 'Report', href: reportUrl(device, row.rom) },
],
- builds: row.variants.map((variant) => ({
- label: [`${labelOf(variant.key)} build`, variant.latest?.date].filter(Boolean).join(', '),
- href: variant.source,
- })),
+ builds: row.variants
+ .filter((variant) => variant.status === 'active')
+ .map((variant) => ({
+ label: [variant.name, variant.latest && formatDate(variant.latest.date)]
+ .filter(Boolean)
+ .join(', '),
+ href: variant.source,
+ })),
},
cells: { ...supportCells(row, features, true), ...romCells(row.rom) },
}));
@@ -65,20 +73,6 @@ const columns = usedColumns(
[...currentRows, ...endedRows],
);
-const monthOf = (date) =>
- new Date(date).toLocaleDateString('en-GB', { month: 'long', year: 'numeric', timeZone: 'UTC' });
-const stockLine = ({ android, security_until, updates_until }) => {
- const until = security_until ?? updates_until;
- if (until === 'ended' || until < today) return 'Stock updates have ended';
- const range = android ? `Stock Android ${android.replace(' - ', ' to ')}` : 'Stock';
- const kind = security_until ? 'security updates' : 'updates';
- return until ? `${range}, ${kind} until ${monthOf(until)}` : range;
-};
-
-const gaps =
- unreported.length === features.length
- ? "Nobody's reported any features on this phone yet."
- : `Nobody's reported ${listOf(unreported.map(({ name }) => name))} on this phone yet.`;
const type = device.type && device.type !== 'phone' ? labelOf(device.type) : undefined;
const romNames = current.map((row) => row.rom.name);
const codename = device.codenames[0];
@@ -115,70 +109,87 @@ const codename = device.codenames[0];
data-pagefind-body
data-pagefind-meta={`hint:${device.brand.name} · ${device.codenames.join(', ')}`}
>
-
- {device.title}
- 1 ? 'Codenames' : 'Codename',
- value: device.codenames.join(', '),
- code: true,
- },
- { label: 'Model numbers', value: device.aliases?.join(', '), code: true },
- { label: 'Released', value: device.released },
- { label: 'Chip', value: device.soc },
- { label: 'Type', value: type },
- ]}
- />
- {stock && (
-
- {stockLine(stock)}, going by {new URL(stock.source).hostname} .
+ 0}
+ >
+ {device.aliases?.length > 0 && (
+
+ Model numbers {device.aliases.join(', ')}
)}
-
+
-
+
- ROMs
+ ROMs {current.length > 0 && {current.length} }
{current.length > 0 ? (
-
+ <>
+
+
+ {current.map((row) => (
+
+
+
+ ))}
+
+
+
+
+
+ >
) : (
- No ROM we track officially supports this one right now. Know different?{' '}
+ None of the ROMs listed here officially supports this device right now. Know of one?{' '}
Add it .
)}
- {ended.length > 0 && (
-
-
- Support that has ended (
- {listOf(
- ended.map((row) => row.rom.name),
- 'and',
- )}
- )
-
+
+
+ {ended.length > 0 && (
+
+ No longer supported
+
+
+ {ended.map((row) => {
+ const build = buildFact(row.latest);
+ return (
+
+
+ {row.rom.name}
+ {build && (
+
+ Last build {build.text}
+ {row.android && `, Android ${row.android}`}
+
+ )}
+
+ );
+ })}
+
+
+
-
- )}
- {support.length > 0 && unreported.length > 0 && (
-
-
- {gaps}{' '}
- Tried one?
-
-
- )}
-
+
+
+ )}
+
+ {support.length > 0 && (
+
+ Tried a ROM on this device? Tell us what works
+
+ )}
@@ -206,4 +215,53 @@ const codename = device.codenames[0];
display: grid;
gap: var(--space-xl);
}
+
+ .models {
+ color: var(--text-muted);
+ font-size: var(--text-sm);
+ }
+
+ .count {
+ margin-inline-start: var(--space-2xs);
+ color: var(--text-muted);
+ font-weight: 400;
+ }
+
+ .roms {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 22rem), 1fr));
+ gap: var(--space-md);
+ margin: 0;
+ padding: 0;
+ list-style: none;
+
+ > li {
+ display: grid;
+ }
+ }
+
+ .ended {
+ display: grid;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+
+ li {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-2xs) var(--space-sm);
+ padding-block: var(--space-xs);
+ border-block-end: 1px solid var(--border);
+ }
+
+ span {
+ color: var(--text-muted);
+ font-size: var(--text-sm);
+ }
+ }
+
+ .report {
+ font-size: var(--text-sm);
+ }
diff --git a/src/pages/devices/[brand]/index.astro b/src/pages/devices/[brand]/index.astro
index e9275d6..3996ccb 100644
--- a/src/pages/devices/[brand]/index.astro
+++ b/src/pages/devices/[brand]/index.astro
@@ -1,7 +1,11 @@
---
import Base from '../../../components/layout/Base.astro';
-import DeviceTables from '../../../components/ui/DeviceTables.astro';
+import PageHead from '../../../components/layout/PageHead.astro';
+import DeviceList from '../../../components/ui/DeviceList.astro';
+import Facts from '../../../components/ui/Facts.astro';
import { getModel } from '../../../lib/data/model.js';
+import { unlockFact } from '../../../lib/facts.js';
+import { plural } from '../../../lib/text.js';
export const getStaticPaths = () =>
getModel().brands.map((brand) => ({ params: { brand: brand.key }, props: { brand } }));
@@ -11,6 +15,7 @@ const { roms } = getModel();
const romNames = [
...new Set(brand.devices.flatMap((device) => device.support.map((row) => row.rom.name))),
];
+const policy = brand.bootloader && unlockFact({ bootloader: brand.bootloader, brand: {} });
---
-
-
+ {policy && (
+
+ )}
+
-
- Devices
-
- Browse by brand
-
- {brands.map((brand) => (
-
- {brand.name} {brand.devices.length}
-
- ))}
-
-
-
-
+
+
+ Browse all {brands.length} brands
+
+
+
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 40bd98c..8f62d6a 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -1,9 +1,12 @@
---
import Base from '../components/layout/Base.astro';
+import RomSummary from '../components/cards/RomSummary.astro';
+import Logo from '../components/ui/Logo.astro';
import Search from '../components/ui/Search.astro';
import { getModel } from '../lib/data/model.js';
+import { formatDate } from '../lib/dates.js';
import { site } from '../lib/site.js';
-import { listOf } from '../lib/text.js';
+import { listOf, plural } from '../lib/text.js';
const { devices, roms } = getModel();
const live = roms.filter((rom) => rom.status === 'active');
@@ -15,6 +18,11 @@ const recent = devices
.filter((device) => device.latestBuild)
.toSorted((a, b) => b.latestBuild.localeCompare(a.latestBuild) || a.title.localeCompare(b.title))
.slice(0, 8);
+const builtBy = (device) =>
+ device.support
+ .filter((row) => row.latest?.date === device.latestBuild)
+ .map((row) => row.rom.name)
+ .join(', ');
---
{popular.map((device) => (
+
{device.title}
- {device.codenames[0]}
- {device.activeCount} ROMs
+ {plural(device.activeCount, 'ROM')}
))}
@@ -54,14 +62,12 @@ const recent = devices
{recent.map((device) => (
+
{device.title}
- {device.support
- .filter((row) => row.latest?.date === device.latestBuild)
- .map((row) => row.rom.name)
- .join(', ')}
+ {builtBy(device)},{' '}
+ {formatDate(device.latestBuild)}
- {device.latestBuild}
))}
@@ -74,9 +80,7 @@ const recent = devices
{live.map((rom) => (
- {rom.name}
- {rom.description}
- {rom.support.filter((row) => row.active).length} devices
+
))}
@@ -115,38 +119,26 @@ const recent = devices
.rows li {
display: grid;
- grid-template-columns: 1fr auto auto;
- align-items: baseline;
- gap: var(--space-md);
+ grid-template-columns: auto 1fr auto;
+ align-items: center;
+ gap: var(--space-sm);
padding-block: var(--space-xs);
border-block-end: 1px solid var(--border);
- > :not(a) {
+ > span {
color: var(--text-muted);
font-size: var(--text-sm);
+ text-align: end;
}
}
.roms {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 22rem), 1fr));
- gap: var(--space-md) var(--space-xl);
+ gap: var(--space-md);
- li {
+ > li {
display: grid;
- gap: var(--space-2xs);
- padding-block: var(--space-sm);
- border-block-start: 2px solid var(--accent);
- }
-
- a {
- font-size: var(--text-lg);
- font-weight: 600;
- }
-
- span {
- color: var(--text-muted);
- font-size: var(--text-sm);
}
}
diff --git a/src/pages/roms/[rom].astro b/src/pages/roms/[rom].astro
index 62c17b0..eebc5b4 100644
--- a/src/pages/roms/[rom].astro
+++ b/src/pages/roms/[rom].astro
@@ -1,11 +1,17 @@
---
import Base from '../../components/layout/Base.astro';
+import PageHead from '../../components/layout/PageHead.astro';
+import CardGrid from '../../components/cards/CardGrid.astro';
+import FeatureList from '../../components/cards/FeatureList.astro';
import Matrix from '../../components/table/Matrix.astro';
import Facts from '../../components/ui/Facts.astro';
-import RomFeatures from '../../components/ui/RomFeatures.astro';
+import FilterBar from '../../components/ui/FilterBar.astro';
+import Icon from '../../components/ui/Icon.astro';
import { getModel } from '../../lib/data/model.js';
+import { byBrand, romDeviceCard } from '../../lib/cards.js';
+import { everywhereCells, googleApps, knownFeatures, romRelock } from '../../lib/facts.js';
import {
- deviceSearchText,
+ filterData,
reportedFeatures,
splitByActive,
supportCells,
@@ -14,6 +20,7 @@ import {
usedColumns,
} from '../../lib/table.js';
import { labelOf, yesNo } from '../../lib/labels.js';
+import { plural } from '../../lib/text.js';
export const getStaticPaths = () =>
getModel().roms.map((rom) => ({ params: { rom: rom.key }, props: { rom } }));
@@ -23,13 +30,13 @@ const { features } = getModel();
const reported = reportedFeatures(features, rom.support);
const [current, ended] = splitByActive(rom.support);
const brands = [
- ...new Map(rom.support.map((row) => [row.device.brand.key, row.device.brand])).values(),
+ ...new Map(current.map((row) => [row.device.brand.key, row.device.brand])).values(),
].sort((a, b) => a.name.localeCompare(b.name));
+const groupsOf = (list) => byBrand(list, (row) => row.device.brand, romDeviceCard);
const rowsOf = (list) =>
list.map((row) => ({
key: row.device.key,
- brand: row.device.brand.key,
- search: deviceSearchText(row.device),
+ data: filterData(row.device, false),
head: {
label: row.device.title,
href: row.device.url,
@@ -45,8 +52,11 @@ const columns = usedColumns(
[{ key: 'brand', label: 'Brand', group: 'device', hidden: true }, ...supportColumns(reported)],
allRows,
).filter(({ key }) => key !== 'relock' || !sameOnEveryRow(allRows, key));
-const everywhere = [rom, ...(rom.variants ?? [])].filter((build) => build.features);
+const everywhere = [rom, ...(rom.variants ?? [])]
+ .map((build) => ({ build, items: knownFeatures(features, everywhereCells(features, build)) }))
+ .filter(({ items }) => items.length > 0);
const { links, security } = rom;
+const count = plural(current.length, 'device');
---
-
-
- {rom.name}
+
+ 0}
+ >
{rom.description}
- {rom.status === 'discontinued' && (
-
- This ROM is discontinued.
-
- )}
+
+
+
+
+ At a glance
-
- {Object.entries(links).map(([kind, href]) => (
-
- {labelOf(kind)}
-
- ))}
-
{everywhere.length > 0 && (
- What works everywhere
- {everywhere.map((build) => (
+ On every device
+ {everywhere.map(({ build, items }) => (
<>
{build !== rom && {build.name} }
-
+
>
))}
)}
-
- Devices
-
-
- Features show once a ROM's docs or someone's report covers them.{' '}
- Know how one works?
-
-
-
+
+
+ Devices {current.length}
+
+ {current.length > 0 ? (
+
+ ) : (
+ {rom.name} doesn't officially support any devices right now.
+ )}
{ended.length > 0 && (
- {ended.length} devices where support has ended
-
+ {plural(ended.length, 'device')} where support has ended
+
+
+
+
+
+
)}
@@ -157,12 +189,22 @@ const { links, security } = rom;
margin-block-start: var(--space-sm);
}
+ .count {
+ margin-inline-start: var(--space-2xs);
+ color: var(--text-muted);
+ font-weight: 400;
+ }
+
.links {
display: flex;
flex-wrap: wrap;
- gap: var(--space-xs) var(--space-lg);
- margin: 0;
+ gap: var(--space-xs);
+ margin: var(--space-xs) 0 0;
padding: 0;
list-style: none;
}
+
+ details[open] > summary {
+ margin-block-end: var(--space-md);
+ }
diff --git a/src/pages/roms/index.astro b/src/pages/roms/index.astro
index 0d52854..e46cbec 100644
--- a/src/pages/roms/index.astro
+++ b/src/pages/roms/index.astro
@@ -1,5 +1,7 @@
---
import Base from '../../components/layout/Base.astro';
+import PageHead from '../../components/layout/PageHead.astro';
+import RomSummary from '../../components/cards/RomSummary.astro';
import Matrix from '../../components/table/Matrix.astro';
import { getModel } from '../../lib/data/model.js';
import { romCells, romColumns } from '../../lib/table.js';
@@ -8,15 +10,11 @@ import { listOf } from '../../lib/text.js';
const { roms } = getModel();
const columns = [{ key: 'devices', label: 'Devices' }, ...romColumns];
const rowsOf = (list) =>
- list.map((rom) => {
- const devices = rom.support.filter((row) => row.active).length;
- return {
- key: rom.key,
- search: rom.name.toLowerCase(),
- head: { label: rom.name, href: rom.url },
- cells: { devices: { text: devices }, ...romCells(rom) },
- };
- });
+ list.map((rom) => ({
+ key: rom.key,
+ head: { label: rom.name, href: rom.url },
+ cells: { devices: { text: rom.activeCount }, ...romCells(rom) },
+ }));
const live = roms.filter((rom) => rom.status === 'active');
const gone = roms.filter((rom) => rom.status !== 'active');
---
@@ -29,15 +27,26 @@ const gone = roms.filter((rom) => rom.status !== 'active');
)} side by side: Google apps, security, install methods and supported devices.`}
crumbs={[{ label: 'ROMs', url: '/roms/' }]}
>
- ROMs
-
+
+
+
+ {live.map((rom) => (
+
+
+
+ ))}
+
+
+
+
+
{gone.length > 0 && (
Discontinued ROMs
@@ -52,3 +61,18 @@ const gone = roms.filter((rom) => rom.status !== 'active');
)}
+
+
diff --git a/src/scripts/filter.js b/src/scripts/filter.js
new file mode 100644
index 0000000..52b36d9
--- /dev/null
+++ b/src/scripts/filter.js
@@ -0,0 +1,67 @@
+/**
+ * Filters the [data-search] items in every list a FilterBar controls (cards and table rows),
+ * mirrored into the URL. Each select matches the item's space-separated data attribute of the
+ * same name, and ended items are hidden by CSS until "Include ended support" is ticked
+ */
+export function enhanceFilter(form) {
+ const [first, ...others] = form
+ .getAttribute('aria-controls')
+ .split(' ')
+ .map((id) => document.getElementById(id));
+ const itemsOf = (list) => [...list.querySelectorAll('[data-search]')];
+ const lists = [first, ...others].map(itemsOf);
+ const query = form.elements.q;
+ const selects = [...form.querySelectorAll('select')];
+ const ended = form.elements.ended;
+ const count = form.querySelector('[data-count]');
+ const empty = form.querySelector('[data-empty]');
+ const emptyEnded = form.querySelector('[data-empty-ended]');
+ const params = new URLSearchParams(location.search);
+
+ query.value = params.get('q') ?? '';
+ for (const select of selects) {
+ select.value = params.get(select.name) ?? '';
+ if (select.selectedIndex < 0) select.value = '';
+ }
+ if (ended) ended.checked = params.get('ended') === '1';
+
+ const matches = (item, text) =>
+ item.dataset.search.includes(text) &&
+ selects.every(
+ ({ name, value }) => !value || (item.dataset[name] ?? '').split(' ').includes(value),
+ );
+
+ function apply() {
+ const text = query.value.trim().toLowerCase();
+ for (const items of lists) {
+ for (const item of items) item.hidden = !matches(item, text);
+ }
+ const [items] = lists;
+ const showable = items.filter((item) => !item.hidden);
+ const shown = showable.filter((item) => ended?.checked || !('ended' in item.dataset)).length;
+ count.textContent = shown === items.length ? `${shown} shown` : `${shown} of ${items.length}`;
+ empty.hidden = showable.length > 0;
+ if (emptyEnded) emptyEnded.hidden = shown > 0 || showable.length === 0;
+ }
+
+ function save() {
+ const url = new URL(location.href);
+ const state = [
+ ['q', query.value.trim()],
+ ...selects.map(({ name, value }) => [name, value]),
+ ['ended', ended?.checked ? '1' : ''],
+ ];
+ for (const [name, value] of state) {
+ if (value) url.searchParams.set(name, value);
+ else url.searchParams.delete(name);
+ }
+ history.replaceState(history.state, '', url);
+ }
+
+ form.addEventListener('submit', (event) => event.preventDefault());
+ form.addEventListener('input', () => {
+ apply();
+ save();
+ });
+ apply();
+}
diff --git a/src/scripts/matrix.js b/src/scripts/matrix.js
index a4d46c9..9841527 100644
--- a/src/scripts/matrix.js
+++ b/src/scripts/matrix.js
@@ -21,32 +21,22 @@ function showScrollCue(scroller) {
new ResizeObserver(update).observe(scroller);
}
-/** Adds sorting, filtering and column toggles to a matrix, mirrored into the URL when asked */
+/** Adds sorting and column toggles to a matrix, mirrored into the URL when asked */
export function enhanceMatrix(root) {
showScrollCue(root.querySelector('.scroller'));
- const controls = root.querySelector('[data-controls]');
- if (!controls) return;
+ const picker = root.querySelector('.picker');
+ if (!picker) return;
const table = root.querySelector('table');
const body = table.tBodies[0];
- const rows = [...body.rows];
const headers = [...table.tHead.rows[0].cells];
const keys = headers.map((header) => header.dataset.col);
- const filter = controls.querySelector('[name="q"]');
- const brand = controls.querySelector('[name="brand"]');
- const ended = controls.querySelector('[name="ended"]');
- const toggles = [...controls.querySelectorAll('[name="col"]')];
- const count = controls.querySelector('[data-count]');
- const empty = root.querySelector('[data-empty]');
- const emptyEnded = root.querySelector('[data-empty-ended]');
+ const toggles = [...picker.querySelectorAll('[name="col"]')];
const useUrl = root.hasAttribute('data-url-state');
const params = new URLSearchParams(useUrl ? location.search : '');
let hiddenColumns;
let sort = keys.includes((params.get('sort') ?? '').replace(/^-/, '')) ? params.get('sort') : '';
- filter.value = params.get('q') ?? '';
- if (brand) brand.value = params.get('brand') ?? '';
- if (ended) ended.checked = params.get('ended') === '1';
if (params.has('cols')) {
const shown = params.get('cols').split(',');
toggles.forEach((toggle) => (toggle.checked = shown.includes(toggle.value)));
@@ -62,25 +52,6 @@ export function enhanceMatrix(root) {
hiddenColumns.textContent = selectors.length ? `${selectors.join(',')} { display: none }` : '';
}
- function filterRows() {
- const query = filter.value.trim().toLowerCase();
- const picked = brand?.value ?? '';
- let shown = 0;
- let onlyEnded = 0;
- for (const row of rows) {
- const matches =
- row.dataset.search.includes(query) && (picked === '' || row.dataset.brand === picked);
- const hide = !matches || (row.dataset.ended !== undefined && !ended?.checked);
- if (row.hidden !== hide) row.hidden = hide;
- if (!hide) shown++;
- else if (matches) onlyEnded++;
- }
- count.textContent =
- shown === rows.length ? `${rows.length} shown` : `${shown} of ${rows.length} shown`;
- empty.hidden = shown > 0 || onlyEnded > 0;
- if (emptyEnded) emptyEnded.hidden = shown > 0 || onlyEnded === 0;
- }
-
function sortRows() {
const index = keys.indexOf(sort.replace(/^-/, ''));
const direction = sort.startsWith('-') ? -1 : 1;
@@ -90,7 +61,7 @@ export function enhanceMatrix(root) {
});
if (index < 0) return;
body.append(
- ...rows.toSorted(
+ ...[...body.rows].toSorted(
(a, b) => direction * compare(sortValue(a.cells[index]), sortValue(b.cells[index])),
),
);
@@ -101,9 +72,6 @@ export function enhanceMatrix(root) {
const url = new URL(location.href);
const custom = toggles.some((toggle) => toggle.checked !== toggle.defaultChecked);
const state = {
- q: filter.value.trim(),
- brand: brand?.value,
- ended: ended?.checked ? '1' : '',
sort,
cols: custom
? toggles
@@ -116,7 +84,7 @@ export function enhanceMatrix(root) {
if (value) url.searchParams.set(name, value);
else url.searchParams.delete(name);
}
- history.replaceState(null, '', url);
+ history.replaceState(history.state, '', url);
}
for (const header of headers) {
@@ -130,13 +98,6 @@ export function enhanceMatrix(root) {
header.append(button);
}
- const onFilter = () => {
- filterRows();
- saveToUrl();
- };
- filter.addEventListener('input', onFilter);
- brand?.addEventListener('change', onFilter);
- ended?.addEventListener('change', onFilter);
for (const toggle of toggles) {
toggle.addEventListener('change', () => {
showColumns();
@@ -148,6 +109,5 @@ export function enhanceMatrix(root) {
showColumns();
table.querySelectorAll('th[hidden], td[hidden]').forEach((cell) => (cell.hidden = false));
}
- if (filter.value || brand?.value || ended) filterRows();
if (sort) sortRows();
}
diff --git a/src/styles/global.css b/src/styles/base.css
similarity index 53%
rename from src/styles/global.css
rename to src/styles/base.css
index 545aa6f..0beaecc 100644
--- a/src/styles/global.css
+++ b/src/styles/base.css
@@ -1,74 +1,4 @@
-:root {
- color-scheme: light dark;
-
- --surface: #ffffff;
- --surface-raised: #f2f6f7;
- --border: #d5dfe2;
- --border-strong: #b3c2c6;
- --text: #121a1d;
- --text-muted: #53636a;
- --accent: #65b5c3;
- --accent-soft: #e0f1f4;
- --accent-text: #1c6a78;
- --focus: #1c6a78;
-
- --working-bg: #e2f3e5;
- --working-text: #1b5528;
- --partial-bg: #fbefd2;
- --partial-text: #664a00;
- --broken-bg: #fbe2df;
- --broken-text: #8a1f17;
- --unknown-bg: #eef2f3;
- --unknown-text: #53636a;
-
- --font-sans: 'Roboto Variable', Roboto, system-ui, sans-serif;
- --font-mono: 'Roboto Mono Variable', 'Roboto Mono', ui-monospace, monospace;
- --text-xs: 0.8rem;
- --text-sm: 0.875rem;
- --text-base: 1rem;
- --text-lg: clamp(1.125rem, 1.05rem + 0.3vw, 1.25rem);
- --text-xl: clamp(1.375rem, 1.2rem + 0.8vw, 1.75rem);
- --text-2xl: clamp(1.75rem, 1.4rem + 1.6vw, 2.5rem);
-
- --space-2xs: 0.25rem;
- --space-xs: 0.5rem;
- --space-sm: 0.75rem;
- --space-md: 1rem;
- --space-lg: 1.5rem;
- --space-xl: 2.5rem;
- --space-2xl: 4rem;
-
- --radius-sm: 0.25rem;
- --radius: 0.5rem;
- --radius-pill: 999px;
- --shadow: 0 0.5rem 1.5rem -0.5rem rgb(18 26 29 / 0.2);
- --measure: 68ch;
- --page: 80rem;
-}
-
-@media (prefers-color-scheme: dark) {
- :root {
- --surface: #0e1416;
- --surface-raised: #151e21;
- --border: #28353a;
- --border-strong: #3c4d53;
- --text: #e4eef0;
- --text-muted: #98aab0;
- --accent-soft: #173035;
- --accent-text: #7cc6d3;
- --focus: #7cc6d3;
-
- --working-bg: #14301b;
- --working-text: #a3dcb0;
- --partial-bg: #342a0e;
- --partial-text: #efcf7c;
- --broken-bg: #3a1917;
- --broken-text: #f4a8a1;
- --unknown-bg: #1b2529;
- --unknown-text: #98aab0;
- --shadow: 0 0.5rem 1.5rem -0.5rem rgb(0 0 0 / 0.6);
- }
-}
+/* Element defaults, before any component styles */
*,
*::before,
@@ -76,6 +6,11 @@
box-sizing: border-box;
}
+/* Anything that sets its own display would otherwise stay visible when hidden */
+[hidden] {
+ display: none !important;
+}
+
html {
-webkit-text-size-adjust: none;
text-size-adjust: none;
@@ -175,7 +110,7 @@ button {
input[type='search'],
select {
- min-block-size: 2.5rem;
+ min-block-size: var(--control);
padding-inline: var(--space-sm);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
@@ -187,13 +122,10 @@ select {
}
main {
- inline-size: 100%;
- max-inline-size: var(--page);
- margin-inline: auto;
- padding: var(--space-lg) var(--space-md) var(--space-2xl);
display: grid;
align-content: start;
gap: var(--space-xl);
+ padding: var(--space-lg) var(--gutter) var(--space-2xl);
}
section {
diff --git a/src/styles/tokens.css b/src/styles/tokens.css
new file mode 100644
index 0000000..ccb331e
--- /dev/null
+++ b/src/styles/tokens.css
@@ -0,0 +1,83 @@
+/* Design tokens. Components only ever use these, so a theme change happens here and nowhere else */
+
+:root {
+ color-scheme: light dark;
+
+ --surface: #ffffff;
+ --surface-raised: #f2f6f7;
+ --border: #d5dfe2;
+ --border-strong: #b3c2c6;
+ --text: #121a1d;
+ --text-muted: #53636a;
+ --accent: #65b5c3;
+ --accent-soft: #e0f1f4;
+ --accent-text: #1c6a78;
+ --accent-strong: #1c6a78;
+ --on-accent: #ffffff;
+ --focus: #1c6a78;
+
+ --working-bg: #e2f3e5;
+ --working-text: #1b5528;
+ --partial-bg: #fbefd2;
+ --partial-text: #664a00;
+ --broken-bg: #fbe2df;
+ --broken-text: #8a1f17;
+ --unknown-bg: #eef2f3;
+ --unknown-text: #53636a;
+
+ --font-sans: 'Roboto Variable', Roboto, system-ui, sans-serif;
+ --font-mono: 'Roboto Mono Variable', 'Roboto Mono', ui-monospace, monospace;
+ --text-xs: 0.8125rem;
+ --text-sm: 0.875rem;
+ --text-base: 1rem;
+ --text-lg: 1.125rem;
+ --text-xl: 1.375rem;
+ --text-2xl: 2rem;
+
+ --space-2xs: 0.25rem;
+ --space-xs: 0.5rem;
+ --space-sm: 0.75rem;
+ --space-md: 1rem;
+ --space-lg: 1.5rem;
+ --space-xl: 2.5rem;
+ --space-2xl: 4rem;
+
+ --radius-sm: 0.25rem;
+ --radius: 0.5rem;
+ --radius-lg: 0.75rem;
+ --radius-pill: 999px;
+ --shadow-sm: 0 1px 3px rgb(18 26 29 / 0.08);
+ --shadow: 0 0.5rem 1.5rem -0.5rem rgb(18 26 29 / 0.2);
+ --control: 2.5rem;
+ --measure: 68ch;
+ --page: 80rem;
+ /* Lines content up with a centred column of --page, while backgrounds still run edge to edge */
+ --gutter: max(var(--space-md), (100% - var(--page)) / 2 + var(--space-md));
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --surface: #0e1416;
+ --surface-raised: #151e21;
+ --border: #28353a;
+ --border-strong: #3c4d53;
+ --text: #e4eef0;
+ --text-muted: #98aab0;
+ --accent-soft: #173035;
+ --accent-text: #7cc6d3;
+ --accent-strong: #7cc6d3;
+ --on-accent: #0e1416;
+ --focus: #7cc6d3;
+
+ --working-bg: #14301b;
+ --working-text: #a3dcb0;
+ --partial-bg: #342a0e;
+ --partial-text: #efcf7c;
+ --broken-bg: #3a1917;
+ --broken-text: #f4a8a1;
+ --unknown-bg: #1b2529;
+ --unknown-text: #98aab0;
+ --shadow-sm: 0 1px 3px rgb(0 0 0 / 0.4);
+ --shadow: 0 0.5rem 1.5rem -0.5rem rgb(0 0 0 / 0.6);
+ }
+}
diff --git a/src/styles/ui.css b/src/styles/ui.css
new file mode 100644
index 0000000..951ea9d
--- /dev/null
+++ b/src/styles/ui.css
@@ -0,0 +1,243 @@
+/* Shared building blocks. Components lay these out, and keep their own styles scoped */
+
+/* One colour pair and icon per answer, for status words, badges and table cells alike */
+[data-tone='working'] {
+ --icon: url('/icons/working.svg');
+ --tone-text: var(--working-text);
+ --tone-bg: var(--working-bg);
+}
+
+[data-tone='partial'] {
+ --icon: url('/icons/partial.svg');
+ --tone-text: var(--partial-text);
+ --tone-bg: var(--partial-bg);
+}
+
+[data-tone='broken'] {
+ --icon: url('/icons/broken.svg');
+ --tone-text: var(--broken-text);
+ --tone-bg: var(--broken-bg);
+}
+
+[data-tone='unknown'] {
+ --icon: url('/icons/unknown.svg');
+ --tone-text: var(--unknown-text);
+ --tone-bg: transparent;
+}
+
+[data-tone='ended'] {
+ --icon: url('/icons/ended.svg');
+ --tone-text: var(--unknown-text);
+ --tone-bg: var(--unknown-bg);
+}
+
+[data-tone='n/a'] {
+ --icon: url('/icons/na.svg');
+ --tone-text: var(--text-muted);
+ --tone-bg: transparent;
+}
+
+/* An icon painted from --icon in the text colour, so one file serves every theme */
+.status::before,
+.masked::before {
+ content: '';
+ display: inline-block;
+ flex: none;
+ inline-size: 1em;
+ block-size: 1em;
+ background: currentColor;
+ mask: var(--icon) center / contain no-repeat;
+ print-color-adjust: exact;
+}
+
+@media (forced-colors: active) {
+ .status::before,
+ .masked::before {
+ forced-color-adjust: none;
+ background: CanvasText;
+ }
+}
+
+/* Lucide icons from Icon.astro sit in the line of text, unlike other SVGs */
+.lucide {
+ display: inline-block;
+ flex: none;
+ vertical-align: -0.125em;
+}
+
+.status {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2xs);
+ color: var(--tone-text, inherit);
+ font-weight: 500;
+}
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2xs);
+ padding: 0.0625rem var(--space-xs);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-pill);
+ background: var(--surface);
+ color: var(--text);
+ font-size: var(--text-xs);
+ font-weight: 500;
+ line-height: 1.5;
+ white-space: nowrap;
+
+ &[data-tone] {
+ border-color: transparent;
+ background: var(--tone-bg);
+ color: var(--tone-text);
+ }
+}
+
+.button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-xs);
+ min-block-size: var(--control);
+ padding-inline: var(--space-md);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius);
+ background: var(--surface);
+ color: var(--text);
+ font-weight: 500;
+ line-height: 1.2;
+ text-decoration: none;
+ cursor: pointer;
+ transition:
+ background-color 150ms ease-out,
+ border-color 150ms ease-out;
+
+ &:hover {
+ border-color: var(--text-muted);
+ background: var(--surface-raised);
+ }
+
+ &:active {
+ translate: 0 1px;
+ }
+
+ &.primary {
+ border-color: transparent;
+ background: var(--accent-strong);
+ color: var(--on-accent);
+
+ &:hover {
+ background: color-mix(in srgb, var(--accent-strong) 85%, var(--text));
+ }
+ }
+
+ &.ghost {
+ border-color: transparent;
+ background: none;
+ color: var(--accent-text);
+
+ &:hover {
+ background: var(--accent-soft);
+ }
+ }
+
+ &.small {
+ min-block-size: 2rem;
+ padding-inline: var(--space-sm);
+ font-size: var(--text-sm);
+ }
+}
+
+.card {
+ position: relative;
+ display: grid;
+ align-content: start;
+ gap: var(--space-sm);
+ padding: var(--space-md);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ background: var(--surface);
+}
+
+/* A card whose title link covers the whole card, so the card is one big target */
+.card:has(.stretch) {
+ transition:
+ border-color 150ms ease-out,
+ box-shadow 150ms ease-out;
+
+ &:hover {
+ border-color: var(--border-strong);
+ box-shadow: var(--shadow-sm);
+ }
+
+ &:has(.stretch:focus-visible) {
+ outline: 2px solid var(--focus);
+ outline-offset: 2px;
+ }
+}
+
+.stretch {
+ &::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ }
+
+ &:focus-visible {
+ outline: none;
+ }
+}
+
+.filtered {
+ display: grid;
+ gap: var(--space-lg);
+}
+
+/* Controls need JS, and ended items wait for "Include ended support" before they show */
+:root:not(.js) [data-controls] {
+ display: none;
+}
+
+.js .filtered:not(:has([name='ended']:checked)) [data-ended] {
+ display: none;
+}
+
+/* A group hides once filtering leaves it nothing to show */
+.filtered .group:not(:has([data-search]:not([hidden]))) {
+ display: none;
+}
+
+.js
+ .filtered:not(:has([name='ended']:checked))
+ .group:not(:has([data-search]:not([hidden], [data-ended]))) {
+ display: none;
+}
+
+/* Cards | Table. Cards are the default, so a browser without :has() always gets those */
+[data-view] {
+ display: grid;
+ gap: var(--space-md);
+ min-inline-size: 0;
+}
+
+[data-view='table'] {
+ display: none;
+}
+
+:root:has([name='view'][value='table']:checked) {
+ [data-view='cards'] {
+ display: none;
+ }
+
+ [data-view='table'] {
+ display: grid;
+ }
+}
+
+@supports not selector(:has(*)) {
+ .view-switch {
+ display: none;
+ }
+}
diff --git a/tests/facts.test.js b/tests/facts.test.js
new file mode 100644
index 0000000..0828384
--- /dev/null
+++ b/tests/facts.test.js
@@ -0,0 +1,118 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { loadData } from '../src/lib/data/load.js';
+import { buildModel } from '../src/lib/data/model.js';
+import {
+ buildFact,
+ everywhereCells,
+ knownFeatures,
+ needsOf,
+ relockFact,
+ romRelock,
+ statusText,
+ stockFact,
+ unlockFact,
+} from '../src/lib/facts.js';
+
+const model = (now) => buildModel(loadData('tests/fixtures/good'), new Date(now));
+const tidyRow = (now) =>
+ model(now).support.find((row) => row.device.key === 'rocket' && row.rom.key === 'tidyos');
+const wording = (list) => list.map(({ name, text, note, stale }) => [name, text, note, stale]);
+
+test('statuses read as words, and inferred ones as likely', () => {
+ assert.equal(statusText({ value: 'working' }), 'Works');
+ assert.equal(statusText({ value: 'broken', inferred: true }), "Likely doesn't work");
+ assert.equal(statusText({ value: 'basic', inferred: true }), 'Likely basic');
+ assert.equal(statusText({ value: 'L3', inferred: true }), 'Likely L3');
+});
+
+test('cards list only the features with an answer, and say when a report was made', () => {
+ const { features } = model('2026-02-01');
+ assert.deepEqual(wording(knownFeatures(features, tidyRow('2026-02-01').cells)), [
+ ['NFC', 'Partly works', "Payments don't work", undefined],
+ ['Notifications', 'Works', 'Reported 10 Jan 2026 on Android 15, Plus build', undefined],
+ ['Widevine', 'L3', undefined, undefined],
+ ]);
+});
+
+test('an old report says when it was last checked instead of being called stale', () => {
+ const { features } = model('2027-02-01');
+ const push = knownFeatures(features, tidyRow('2027-02-01').cells).find(
+ (feature) => feature.key === 'push',
+ );
+ assert.equal(push.stale, 'Last checked Jan 2026 on Android 15');
+});
+
+test('what a ROM says holds everywhere reads the same way', () => {
+ const { features, roms } = model('2026-02-01');
+ const tidy = roms.find((rom) => rom.key === 'tidyos');
+ assert.deepEqual(wording(knownFeatures(features, everywhereCells(features, tidy))), [
+ ['Notifications', 'Partly works', 'Bring your own', undefined],
+ ['Widevine', 'Likely none', undefined, undefined],
+ ]);
+});
+
+test("the bootloader keeps the device's own note, or borrows the brand policy it follows", () => {
+ const [device] = model('2026-02-01').devices;
+ assert.deepEqual(unlockFact(device), {
+ tone: 'partial',
+ text: 'Unlocks, with conditions',
+ note: 'Carrier models are locked.',
+ source: undefined,
+ });
+ const brand = { bootloader: { unlock: 'yes', notes: 'All unlock.', source: 'https://a.test' } };
+ assert.deepEqual(unlockFact({ bootloader: { unlock: 'yes', origin: 'brand' }, brand }), {
+ tone: 'working',
+ text: 'Unlocks',
+ note: 'All unlock.',
+ source: 'https://a.test',
+ });
+ assert.equal(unlockFact({ bootloader: { unlock: 'no' }, brand }).note, undefined);
+});
+
+test('stock updates read as a date, until they run out', () => {
+ const { stock } = model('2026-02-01').devices[0];
+ assert.deepEqual(
+ [stockFact(stock, '2026-02-01').text, stockFact(stock, '2026-02-01').note],
+ ['Security updates until Jan 2027', 'Android 13 to 15'],
+ );
+ assert.equal(stockFact(stock, '2027-02-01').text, 'Updates have ended');
+ assert.deepEqual(stockFact({ android: '13 - 15', source: 'https://a.test' }), {
+ text: 'Android 13 to 15',
+ source: 'https://a.test',
+ });
+ assert.equal(stockFact(undefined), undefined);
+});
+
+test('builds carry a readable date, their patch month, and say so once they are old', () => {
+ const latest = { version: '1.2', date: '2026-01-09', patch: '2026-01-01' };
+ assert.deepEqual(buildFact(latest, Date.parse('2026-02-01')), {
+ date: '2026-01-09',
+ tone: 'working',
+ text: '9 Jan 2026',
+ old: undefined,
+ version: '1.2',
+ patch: 'Jan 2026',
+ });
+ assert.equal(buildFact(latest, Date.parse('2026-09-01')).old, 'Over 6 months old');
+ assert.equal(buildFact(undefined), undefined);
+});
+
+test('relock and install needs only say what the ROM says', () => {
+ assert.equal(relockFact(tidyRow('2026-02-01')).text, 'Bootloader relocks');
+ assert.equal(relockFact({ relock: 'unknown' }), undefined);
+ assert.equal(relockFact({}), undefined);
+ assert.deepEqual(needsOf({ firmware: '16', channel: 'beta' }), [
+ 'Stock Android 16 first',
+ 'Beta build',
+ ]);
+ assert.deepEqual(needsOf({ channel: 'stable' }), []);
+});
+
+test("a ROM's relock answer gives way when its own device pages say otherwise", () => {
+ const rom = { security: { relockable_bootloader: false }, support: [{ active: true }] };
+ assert.equal(romRelock(rom), 'No');
+ rom.support.push({ active: true, relock: { status: 'yes', note: 'Check first' } });
+ assert.equal(romRelock(rom), 'On some devices');
+ assert.equal(romRelock({ security: { relockable_bootloader: true }, support: [] }), 'Yes');
+});
diff --git a/tests/logos.test.js b/tests/logos.test.js
new file mode 100644
index 0000000..f97cb10
--- /dev/null
+++ b/tests/logos.test.js
@@ -0,0 +1,24 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readdirSync, readFileSync } from 'node:fs';
+import { parse } from 'yaml';
+
+const logos = (kind) =>
+ readdirSync(`src/assets/logos/${kind}`).map((file) => [file.replace(/\.svg$/, ''), file]);
+
+test('every logo is named after a brand or ROM, so none silently falls back to initials', () => {
+ const brands = new Set(parse(readFileSync('data/brands.yml', 'utf8')).map(({ key }) => key));
+ const roms = new Set(readdirSync('data/roms').map((file) => file.replace(/\.yml$/, '')));
+ for (const [key, file] of logos('brands')) assert.ok(brands.has(key), `brands/${file}`);
+ for (const [key, file] of logos('roms')) assert.ok(roms.has(key), `roms/${file}`);
+});
+
+test('logos are plain single-colour marks with a viewBox and nothing that runs', () => {
+ for (const kind of ['brands', 'roms']) {
+ for (const [, file] of logos(kind)) {
+ const svg = readFileSync(`src/assets/logos/${kind}/${file}`, 'utf8');
+ assert.match(svg, /^/, file);
+ assert.doesNotMatch(svg, /script|foreignObject|href|\son\w+=|style=/i, file);
+ }
+ }
+});
From 783f90a7f6e1e7e519e5a3cee3152f0b9546d5e0 Mon Sep 17 00:00:00 2001
From: Iain
Date: Tue, 6 Oct 2026 20:27:22 +0100
Subject: [PATCH 2/2] feat: sort and filter device lists, credit maintainers,
tidy ROM cards
- "Report what works" fills in the device, ROM and Android version on the
form, not just the title
- Device lists sort by newest or most ROMs, and filter by type and hardware.
Each dropdown only offers choices that narrow the list, so a page of Pixels
that all unlock gets no bootloader filter
- Brand tables start with a ROM's column hidden when it has never supported
any of that brand's devices
- ROM cards read the same way every time: one build line, relock as the first
item in the checklist, then what installing takes (stock Android first,
other builds, the maintainer) above the buttons
- Evolution X maintainers link to their GitHub accounts, so a row's
maintainer can now be { name, link } as well as a name
- Cards and panels sit on a subtle surface of their own in dark mode
Co-Authored-By: Claude Opus 5.5
---
.github/CONTRIBUTING.md | 4 +-
schema/upstream.json | 15 ++-
scripts/import/sources/evolutionx.js | 7 +-
src/components/cards/CardGrid.astro | 6 +
src/components/cards/FeatureList.astro | 2 +-
src/components/cards/RomCard.astro | 180 +++++++++++++++----------
src/components/table/Cell.astro | 2 +
src/components/ui/DeviceList.astro | 12 +-
src/components/ui/Facts.astro | 1 +
src/components/ui/FilterBar.astro | 73 +++++-----
src/components/ui/Icon.astro | 6 +
src/lib/cards.js | 8 +-
src/lib/facts.js | 38 ++++--
src/lib/filters.js | 53 ++++++++
src/lib/table.js | 46 ++++---
src/pages/devices/[brand]/[slug].astro | 2 +-
src/pages/roms/[rom].astro | 7 +-
src/scripts/filter.js | 34 ++++-
src/styles/tokens.css | 3 +
src/styles/ui.css | 6 +-
tests/facts.test.js | 18 ++-
tests/filters.test.js | 45 +++++++
tests/import/evolutionx.test.js | 2 +-
tests/report.test.js | 17 +++
24 files changed, 416 insertions(+), 171 deletions(-)
create mode 100644 src/lib/filters.js
create mode 100644 tests/filters.test.js
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 14425ef..4589745 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -27,7 +27,7 @@ The files in `upstream/` get rewritten by a bot every Monday, so any change you
## Reporting what works
-The [form](https://github.com/NotAFlightRisk/android-rom-compat/issues/new?template=report-feature.yml) is the easy way. Pick a device and ROM, set whatever you've tried, and a bot opens a pull request for you with your name on the commit. If something's missing or doesn't add up, it comments on the issue saying what - just edit the issue and it'll try again. The "Report what works" button on each ROM on a device page puts the device, its codename and the ROM in the title, so you can copy them across.
+The [form](https://github.com/NotAFlightRisk/android-rom-compat/issues/new?template=report-feature.yml) is the easy way. Pick a device and ROM, set whatever you've tried, and a bot opens a pull request for you with your name on the commit. If something's missing or doesn't add up, it comments on the issue saying what - just edit the issue and it'll try again. The "Report what works" button on each ROM on a device page fills in the device, the ROM and its Android version for you.
Rather do it by hand? Add a `features` section to `data/support//.yml`, making the file if it's not there yet. Anything you leave out shows as unknown, which is fine.
@@ -109,7 +109,7 @@ source: https://example.com/devices/tegu
install: https://example.com/devices/tegu/install
```
-`status` is `active`, or `discontinued` once the ROM stops building for it. `status` and `source` are the only must-haves.
+`status` is `active`, or `discontinued` once the ROM stops building for it. `status` and `source` are the only must-haves. `maintainer` can also be `{ name: someone, link: https://github.com/someone }`, to link to them.
A new ROM lives at `data/roms/.yml`. Keep that name, and any variant key, to lowercase letters and numbers - imports are named `-.yml`, so a hyphen in either splits in the wrong place.
diff --git a/schema/upstream.json b/schema/upstream.json
index 8781d28..1055ece 100644
--- a/schema/upstream.json
+++ b/schema/upstream.json
@@ -21,7 +21,20 @@
"status": { "enum": ["active", "discontinued"] },
"channel": { "enum": ["stable", "community", "beta"] },
"android": { "type": "number", "minimum": 1, "maximum": 99 },
- "maintainer": { "type": "string", "minLength": 1 },
+ "maintainer": {
+ "anyOf": [
+ { "type": "string", "minLength": 1 },
+ {
+ "type": "object",
+ "required": ["name", "link"],
+ "additionalProperties": false,
+ "properties": {
+ "name": { "type": "string", "minLength": 1 },
+ "link": { "$ref": "#/$defs/link" }
+ }
+ }
+ ]
+ },
"relock": {
"anyOf": [
{ "enum": ["yes", "no", "unknown"] },
diff --git a/scripts/import/sources/evolutionx.js b/scripts/import/sources/evolutionx.js
index 564228d..2ec309d 100644
--- a/scripts/import/sources/evolutionx.js
+++ b/scripts/import/sources/evolutionx.js
@@ -24,6 +24,11 @@ export const typeOfName = (name) => (/\b(pad|tab|tablet)\b/i.test(name) ? 'table
export const dateOf = (seconds) => isoDate(seconds * 1000);
+const maintainerOf = ({ maintainer, github }) =>
+ /^[a-z\d-]+$/i.test(github ?? '')
+ ? { name: maintainer, link: `https://github.com/${github}` }
+ : maintainer;
+
/** Brand, device and row from one builds/.json, or undefined when it has no builds */
export function parse(json, { codename, branch }) {
const build = json.response?.[0];
@@ -34,7 +39,7 @@ export function parse(json, { codename, branch }) {
const row = {
status: build.currently_maintained ? 'active' : 'discontinued',
android: androidOf[branch],
- ...(build.maintainer && { maintainer: build.maintainer }),
+ ...(build.maintainer && { maintainer: maintainerOf(build) }),
...(build.timestamp > 0 && {
latest: {
...(build.version && { version: String(build.version) }),
diff --git a/src/components/cards/CardGrid.astro b/src/components/cards/CardGrid.astro
index 887895a..92fd8dc 100644
--- a/src/components/cards/CardGrid.astro
+++ b/src/components/cards/CardGrid.astro
@@ -19,6 +19,7 @@ const Heading = `h${level}`;
{items.map((item) => (
+ {brand && item.prefix && {item.prefix} }
{item.title}
{item.code}
@@ -99,6 +100,11 @@ const Heading = `h${level}`;
text-decoration: none;
}
+ /* Only a sorted list, which has no brand headings, needs the brand in each name */
+ .group:not(.sorted) .prefix {
+ display: none;
+ }
+
.card > code,
.meta {
color: var(--text-muted);
diff --git a/src/components/cards/FeatureList.astro b/src/components/cards/FeatureList.astro
index 81adb3d..535ffd0 100644
--- a/src/components/cards/FeatureList.astro
+++ b/src/components/cards/FeatureList.astro
@@ -1,5 +1,5 @@
---
-// What works, one feature per line, from knownFeatures() in src/lib/facts.js
+// What works, one line each, from relockFact() and knownFeatures() in src/lib/facts.js
import Icon from '../ui/Icon.astro';
const { items } = Astro.props;
diff --git a/src/components/cards/RomCard.astro b/src/components/cards/RomCard.astro
index 8826a60..5d7bce1 100644
--- a/src/components/cards/RomCard.astro
+++ b/src/components/cards/RomCard.astro
@@ -1,18 +1,27 @@
---
-// One ROM on one device: its build, what it needs, what works, and where to install it from
+// One ROM on one device: its build, what works, and what installing it takes
import FeatureList from './FeatureList.astro';
import Icon from '../ui/Icon.astro';
import Logo from '../ui/Logo.astro';
-import { buildFact, googleApps, knownFeatures, needsOf, relockFact } from '../../lib/facts.js';
+import {
+ buildFact,
+ channelText,
+ firmwareNeed,
+ googleApps,
+ knownFeatures,
+ maintainerOf,
+ relockFact,
+} from '../../lib/facts.js';
import { formatDate } from '../../lib/dates.js';
import { reportUrl } from '../../lib/table.js';
const { row, features } = Astro.props;
const { rom, device } = row;
const build = buildFact(row.latest);
-const relock = relockFact(row);
-const needs = needsOf(row);
-const known = knownFeatures(features, row.cells);
+const channel = channelText(row);
+const checks = [relockFact(row), ...knownFeatures(features, row.cells)].filter(Boolean);
+const firmware = firmwareNeed(row);
+const maintainer = maintainerOf(row);
const builds = row.variants.filter((variant) => variant.status === 'active');
const id = `rom-${rom.key}`;
---
@@ -29,50 +38,67 @@ const id = `rom-${rom.key}`;
{row.android && Android {row.android} }
-
- {build && (
-
- Built {build.text}
- {build.old && · {build.old} }
-
+ {(build || channel) && (
+
+
+ {build && (
+
+ Built {build.text}
+
+ )}
+ {build?.old && (
+
+ {build.old}
+
+ )}
+ {build?.patch && {build.patch} security patch }
+ {channel && {channel} }
+
+
+ )}
+
+ {checks.length > 0 && }
+
+
+ {firmware && (
+
)}
- {build?.patch && {build.patch} security patch }
- {relock && (
-
-
- {relock.text}
+ {builds.map((variant) => (
+
- )}
- {needs.map((need) => (
- {need}
+
))}
-
-
- {known.length > 0 && }
-
- {builds.map((variant) => (
-
- Also as {variant.name}
- {variant.latest && <>, built {formatDate(variant.latest.date)}>}
-
- ))}
-
-
+
+
diff --git a/src/components/table/Cell.astro b/src/components/table/Cell.astro
index 0a9471a..c273ea4 100644
--- a/src/components/table/Cell.astro
+++ b/src/components/table/Cell.astro
@@ -8,6 +8,8 @@ const noteId = `${id}-note`;
{cell.text ?? cell.date}
) : cell.code ? (
{cell.text}
+ ) : cell.href ? (
+ {cell.text}
) : (
cell.text
)}
diff --git a/src/components/ui/DeviceList.astro b/src/components/ui/DeviceList.astro
index 66167dc..7238574 100644
--- a/src/components/ui/DeviceList.astro
+++ b/src/components/ui/DeviceList.astro
@@ -4,6 +4,7 @@ import CardGrid from '../cards/CardGrid.astro';
import Matrix from '../table/Matrix.astro';
import FilterBar from './FilterBar.astro';
import { byBrand, deviceCard } from '../../lib/cards.js';
+import { deviceFilters } from '../../lib/filters.js';
import { deviceColumns, deviceRows } from '../../lib/table.js';
const { id, label, devices, roms, brands = [] } = Astro.props;
@@ -22,13 +23,10 @@ const toCard = (device) => keepEnded(deviceCard(device, live, !stated));
const groups = brands.length
? byBrand(devices, (device) => device.brand, toCard)
: [{ items: devices.map(toCard) }];
-const columns = deviceColumns(live).map((column) => ({
+const columns = deviceColumns(live, devices).map((column) => ({
...column,
- hidden: column.key === 'brand' && !brands.length,
+ hidden: column.hidden || (column.key === 'brand' && !brands.length),
}));
-const supported = live.filter((rom) =>
- devices.some((device) => device.support.some((row) => row.rom === rom && row.active)),
-);
---
@@ -36,9 +34,7 @@ const supported = live.filter((rom) =>
controls={[`${id}-cards`, id]}
label={`Filter ${label.toLowerCase()}`}
total={devices.length}
- brands={brands}
- roms={supported}
- unlock
+ filters={deviceFilters(devices, live)}
endedCount={hideEnded ? devices.filter((device) => device.activeCount === 0).length : 0}
/>
diff --git a/src/components/ui/Facts.astro b/src/components/ui/Facts.astro
index 3e7c85d..b35e817 100644
--- a/src/components/ui/Facts.astro
+++ b/src/components/ui/Facts.astro
@@ -69,6 +69,7 @@ const shown = items.filter((item) => item.value !== undefined && item.value !==
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
+ background: var(--surface-card);
> div {
display: grid;
diff --git a/src/components/ui/FilterBar.astro b/src/components/ui/FilterBar.astro
index 6224413..e92cdb1 100644
--- a/src/components/ui/FilterBar.astro
+++ b/src/components/ui/FilterBar.astro
@@ -1,21 +1,12 @@
---
-// Search and dropdowns that filter the cards and the table together. Hidden without JS
+// Search and filters for the cards and the table together, and a sort for the cards. Needs JS
import Icon from './Icon.astro';
-const {
- controls,
- label,
- total,
- brands = [],
- roms = [],
- unlock = false,
- endedCount = 0,
-} = Astro.props;
-const unlocks = [
- ['yes', 'Unlocks'],
- ['conditional', 'Unlocks, with conditions'],
- ['no', "Doesn't unlock"],
- ['unknown', 'Unknown'],
+const { controls, label, total, filters = [], endedCount = 0 } = Astro.props;
+const orders = [
+ ['', 'A to Z'],
+ ['newest', 'Newest first'],
+ ['roms', 'Most ROMs first'],
];
---
@@ -37,39 +28,25 @@ const unlocks = [
spellcheck="false"
/>
- {brands.length > 1 && (
+ {filters.map(({ name, label, any, options }) => (
- Brand
-
- All brands
- {brands.map((brand) => (
- {brand.name}
- ))}
-
-
- )}
- {roms.length > 1 && (
-
- ROM
-
- Any ROM
- {roms.map((rom) => (
- {rom.name}
- ))}
-
-
- )}
- {unlock && (
-
- Bootloader
-
- Any bootloader
- {unlocks.map(([value, text]) => (
+ {label}
+
+ {any}
+ {options.map(([value, text]) => (
{text}
))}
- )}
+ ))}
+
+ Sort
+
+ {orders.map(([value, text]) => (
+ {text}
+ ))}
+
+
{endedCount > 0 && (
@@ -126,6 +103,16 @@ const unlocks = [
max-inline-size: 100%;
}
+ @media (width < 40rem) {
+ label:has(select) {
+ flex: 1 1 40%;
+ }
+
+ select {
+ inline-size: 100%;
+ }
+ }
+
.check {
display: flex;
align-items: center;
diff --git a/src/components/ui/Icon.astro b/src/components/ui/Icon.astro
index 30ab3c7..3dc090a 100644
--- a/src/components/ui/Icon.astro
+++ b/src/components/ui/Icon.astro
@@ -6,11 +6,14 @@ import CodeXml from '@lucide/astro/icons/code-xml';
import Columns3 from '@lucide/astro/icons/columns-3';
import Flag from '@lucide/astro/icons/flag';
import Globe from '@lucide/astro/icons/globe';
+import Info from '@lucide/astro/icons/info';
+import Layers from '@lucide/astro/icons/layers';
import LayoutGrid from '@lucide/astro/icons/layout-grid';
import MessagesSquare from '@lucide/astro/icons/messages-square';
import Pencil from '@lucide/astro/icons/pencil';
import Search from '@lucide/astro/icons/search';
import Table2 from '@lucide/astro/icons/table-2';
+import UserRound from '@lucide/astro/icons/user-round';
const icons = {
cards: LayoutGrid,
@@ -20,10 +23,13 @@ const icons = {
edit: Pencil,
external: ArrowUpRight,
install: BookOpen,
+ maintainer: UserRound,
+ needs: Info,
report: Flag,
search: Search,
source: CodeXml,
table: Table2,
+ variant: Layers,
website: Globe,
};
diff --git a/src/lib/cards.js b/src/lib/cards.js
index 815a68e..118afdd 100644
--- a/src/lib/cards.js
+++ b/src/lib/cards.js
@@ -1,5 +1,6 @@
import { buildFact, lockTone, unlockFact } from './facts.js';
-import { filterData, romStatus } from './table.js';
+import { filterData } from './filters.js';
+import { romStatus } from './table.js';
/** Splits items into one group per brand, in brand order, for a CardGrid */
export const byBrand = (list, brandOf, toItem) =>
@@ -7,6 +8,9 @@ export const byBrand = (list, brandOf, toItem) =>
.sort(([a], [b]) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }))
.map(([brand, items]) => ({ brand, items: items.map(toItem) }));
+/** Cards leave out the brand their group's heading shows, so a sorted list puts it back */
+const prefixOf = (device) => (device.title === device.name ? undefined : device.brand.name);
+
/** A card's meta line, e.g. "Android 16 · Built 2 Oct 2026", skipping what's missing */
const metaLine = (...parts) => parts.filter(Boolean).join(' · ');
@@ -21,6 +25,7 @@ const unlockStatus = (device) => {
export const deviceCard = (device, roms, showUnlock = true) => ({
key: device.key,
href: device.url,
+ prefix: prefixOf(device),
title: device.name,
code: device.codenames[0],
meta: metaLine(device.released),
@@ -40,6 +45,7 @@ export const romDeviceCard = (row) => {
return {
key: row.device.key,
href: row.device.url,
+ prefix: prefixOf(row.device),
title: row.device.name,
code: row.device.codenames[0],
meta: metaLine(row.android && `Android ${row.android}`, build && `Built ${build.text}`),
diff --git a/src/lib/facts.js b/src/lib/facts.js
index 6238f52..2316874 100644
--- a/src/lib/facts.js
+++ b/src/lib/facts.js
@@ -1,6 +1,6 @@
import { labelOf, yesNo } from './labels.js';
import { formatDate, formatMonth, today } from './dates.js';
-import { isLink } from './text.js';
+import { isLink, listOf } from './text.js';
import { resolveCell } from './data/status.js';
/** Builds read as fresh under two months old, ageing up to six, and old after that */
@@ -17,7 +17,7 @@ export const lockTone = {
unknown: 'unknown',
};
-const unlockWords = {
+export const unlockWords = {
yes: 'Unlocks',
conditional: 'Unlocks, with conditions',
no: "Doesn't unlock",
@@ -78,20 +78,36 @@ export const romRelock = (rom) => {
return differs ? 'On some devices' : yesNo(relockable);
};
-const relockWords = { yes: 'Bootloader relocks', no: "Can't relock the bootloader" };
+const relockWords = { yes: 'Supported', no: 'Not supported' };
-/** Only says something when the ROM's device page does */
+/** Relocking as a line in a card's checklist, when the ROM gives an answer */
export function relockFact(row) {
const { status, note } = relockOf(row);
- return relockWords[status] && { tone: lockTone[status], text: relockWords[status], note };
+ return (
+ relockWords[status] && {
+ name: 'Bootloader relock',
+ tone: lockTone[status],
+ text: relockWords[status],
+ note,
+ }
+ );
}
-/** What has to happen before installing, e.g. "Stock Android 16 first" or "Beta build" */
-export const needsOf = ({ firmware, channel }) =>
- [
- firmware && (/^\d/.test(firmware) ? `Stock Android ${firmware} first` : firmware),
- channel && channel !== 'stable' && `${labelOf(channel)} build`,
- ].filter(Boolean);
+export const maintainerOf = ({ maintainer }) =>
+ typeof maintainer === 'string' ? { name: maintainer } : maintainer;
+
+/** What to put on the device before the ROM, e.g. "Install stock Android 13 or 14 first" */
+export const firmwareNeed = ({ firmware }) =>
+ firmware && /^\d/.test(firmware)
+ ? `Install stock Android ${listOf(firmware.split('/'))} first`
+ : firmware;
+
+/** A build that isn't a stable release, e.g. "Community build" or "Beta build" */
+export const channelText = ({ channel }) =>
+ channel && channel !== 'stable' ? `${labelOf(channel)} build` : undefined;
+
+/** Everything that comes with installing, for the table's Needs column */
+export const needsOf = (row) => [firmwareNeed(row), channelText(row)].filter(Boolean);
const googleWords = {
none: 'No Google apps',
diff --git a/src/lib/filters.js b/src/lib/filters.js
new file mode 100644
index 0000000..1e6d9d9
--- /dev/null
+++ b/src/lib/filters.js
@@ -0,0 +1,53 @@
+import { unlockWords } from './facts.js';
+
+export const deviceSearchText = (device) =>
+ [device.title, ...device.codenames, ...(device.aliases ?? [])].join(' ').toLowerCase();
+
+/** What FilterBar matches and sorts on, spread onto a device's card and its table row alike */
+export const filterData = (device, ended = device.activeCount === 0) => ({
+ 'data-search': deviceSearchText(device),
+ 'data-brand': device.brand.key,
+ 'data-rom': device.support
+ .filter((row) => row.active)
+ .map((row) => row.rom.key)
+ .join(' '),
+ 'data-unlock': device.bootloader.unlock,
+ 'data-type': device.type ?? 'phone',
+ 'data-hardware': device.hardware?.join(' '),
+ 'data-released': device.released,
+ 'data-ended': ended ? '' : undefined,
+});
+
+const types = { phone: 'Phones', tablet: 'Tablets', handheld: 'Handhelds' };
+const hardware = { '5g': 'With 5G', esim: 'With eSIM', nfc: 'With NFC' };
+
+/**
+ * FilterBar's dropdowns for a list of devices. Each keeps only the choices that narrow the list
+ * down, so a page of Pixels that all unlock gets no bootloader dropdown
+ */
+export function deviceFilters(devices, roms = []) {
+ const items = devices.map((device) => filterData(device));
+ const narrows = (name, value) => {
+ const hits = items.filter((data) =>
+ (data[`data-${name}`] ?? '').split(' ').includes(value),
+ ).length;
+ return hits > 0 && hits < items.length;
+ };
+ const brands = [...new Set(devices.map((device) => device.brand))]
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .map((brand) => [brand.key, brand.name]);
+ return [
+ ['brand', 'Brand', 'All brands', brands],
+ ['rom', 'ROM', 'Any ROM', roms.map((rom) => [rom.key, rom.name])],
+ ['unlock', 'Bootloader', 'Any bootloader', Object.entries(unlockWords)],
+ ['type', 'Type', 'All devices', Object.entries(types)],
+ ['hardware', 'Hardware', 'Any hardware', Object.entries(hardware)],
+ ]
+ .map(([name, label, any, options]) => ({
+ name,
+ label,
+ any,
+ options: options.filter(([value]) => narrows(name, value)),
+ }))
+ .filter(({ options }) => options.length > 0);
+}
diff --git a/src/lib/table.js b/src/lib/table.js
index 1a079d0..ca84490 100644
--- a/src/lib/table.js
+++ b/src/lib/table.js
@@ -2,10 +2,12 @@ import { labelOf, yesNo } from './labels.js';
import { issueUrl } from './site.js';
import { isLink } from './text.js';
import { formatMonth } from './dates.js';
+import { filterData } from './filters.js';
import {
ageTone,
buildFact,
lockTone,
+ maintainerOf,
needsOf,
relockOf,
romRelock,
@@ -70,6 +72,11 @@ export const needsCell = (row) => {
return chips.length ? { chips } : {};
};
+const maintainerCell = (row) => {
+ const { name, link } = maintainerOf(row) ?? {};
+ return { text: name, href: link };
+};
+
const featureCell = (cell, detailed) => ({
tone: cell.tone,
text: statusText(cell),
@@ -86,6 +93,7 @@ export const supportCells = (row, features, detailed = false) => ({
patch: row.latest?.patch ? { date: row.latest.patch, text: formatMonth(row.latest.patch) } : {},
relock: relockCell(row),
needs: needsCell(row),
+ maintainer: maintainerCell(row),
...Object.fromEntries(features.map(({ key }) => [key, featureCell(row.cells[key], detailed)])),
});
@@ -100,6 +108,7 @@ export const supportColumns = (features, detailed = false) => [
: [buildColumn('version', 'Version', true), buildColumn('patch', 'Patch', true)]),
buildColumn('relock', 'Relock'),
buildColumn('needs', 'Needs'),
+ buildColumn('maintainer', 'Maintainer', true),
...featureColumns(features),
];
@@ -121,36 +130,33 @@ export const reportedFeatures = (features, rows) =>
export const featuresFrom = (features, rows, origin) =>
features.filter(({ key }) => rows.every((row) => row.cells[key].origin === origin));
-export const reportUrl = (device, rom) => {
- const title = `[Report]: ${device.title} (${device.key})${rom ? ` on ${rom.name}` : ''}`;
- return `${issueUrl('report-feature')}&title=${encodeURIComponent(title)}`;
+/** The report form, with the device, and the ROM and its Android version when there is one */
+export const reportUrl = (device, row) => {
+ const rom = row?.rom;
+ const fields = new URLSearchParams({
+ title: `[Report]: ${device.title} (${device.key})${rom ? ` on ${rom.name}` : ''}`,
+ device: device.key,
+ ...(rom && { rom: rom.name }),
+ ...(row?.android && { android: row.android }),
+ });
+ return `${issueUrl('report-feature')}&${fields}`;
};
-export const deviceSearchText = (device) =>
- [device.title, ...device.codenames, ...(device.aliases ?? [])].join(' ').toLowerCase();
-
-/** What FilterBar matches on, spread onto a device's card and its table row alike */
-export const filterData = (device, ended = device.activeCount === 0) => ({
- 'data-search': deviceSearchText(device),
- 'data-brand': device.brand.key,
- 'data-rom': device.support
- .filter((row) => row.active)
- .map((row) => row.rom.key)
- .join(' '),
- 'data-unlock': device.bootloader.unlock,
- 'data-ended': ended ? '' : undefined,
-});
-
export const splitByActive = (rows) => [
rows.filter((row) => row.active),
rows.filter((row) => !row.active),
];
-export const deviceColumns = (roms) => [
+/** A ROM that has never supported any of these devices starts with its column hidden */
+export const deviceColumns = (roms, devices) => [
{ key: 'brand', label: 'Brand' },
{ key: 'released', label: 'Released' },
{ key: 'unlock', label: 'Bootloader unlock' },
- ...roms.map((rom) => ({ key: rom.key, label: rom.name })),
+ ...roms.map((rom) => ({
+ key: rom.key,
+ label: rom.name,
+ hidden: !devices.some((device) => device.support.some((row) => row.rom === rom)),
+ })),
];
/** Whether a ROM supports a device, for its column on device lists and its chip on cards */
diff --git a/src/pages/devices/[brand]/[slug].astro b/src/pages/devices/[brand]/[slug].astro
index b89fd4b..490c241 100644
--- a/src/pages/devices/[brand]/[slug].astro
+++ b/src/pages/devices/[brand]/[slug].astro
@@ -47,7 +47,7 @@ const rowsOf = (list) =>
href: row.rom.url,
links: [
...(row.install ? [{ label: 'Install guide', href: row.install }] : []),
- { label: 'Report', href: reportUrl(device, row.rom) },
+ { label: 'Report', href: reportUrl(device, row) },
],
builds: row.variants
.filter((variant) => variant.status === 'active')
diff --git a/src/pages/roms/[rom].astro b/src/pages/roms/[rom].astro
index eebc5b4..2929b0d 100644
--- a/src/pages/roms/[rom].astro
+++ b/src/pages/roms/[rom].astro
@@ -9,9 +9,9 @@ import FilterBar from '../../components/ui/FilterBar.astro';
import Icon from '../../components/ui/Icon.astro';
import { getModel } from '../../lib/data/model.js';
import { byBrand, romDeviceCard } from '../../lib/cards.js';
+import { deviceFilters, filterData } from '../../lib/filters.js';
import { everywhereCells, googleApps, knownFeatures, romRelock } from '../../lib/facts.js';
import {
- filterData,
reportedFeatures,
splitByActive,
supportCells,
@@ -29,9 +29,6 @@ const { rom } = Astro.props;
const { features } = getModel();
const reported = reportedFeatures(features, rom.support);
const [current, ended] = splitByActive(rom.support);
-const brands = [
- ...new Map(current.map((row) => [row.device.brand.key, row.device.brand])).values(),
-].sort((a, b) => a.name.localeCompare(b.name));
const groupsOf = (list) => byBrand(list, (row) => row.device.brand, romDeviceCard);
const rowsOf = (list) =>
list.map((row) => ({
@@ -138,7 +135,7 @@ const count = plural(current.length, 'device');
controls={['rom-devices-cards', 'rom-devices']}
label="Filter devices"
total={current.length}
- brands={brands}
+ filters={deviceFilters(current.map((row) => row.device))}
/>
diff --git a/src/scripts/filter.js b/src/scripts/filter.js
index 52b36d9..b96ce2f 100644
--- a/src/scripts/filter.js
+++ b/src/scripts/filter.js
@@ -1,7 +1,25 @@
+const ranks = {
+ newest: (item) => Number(item.dataset.released ?? 0),
+ roms: (item) => (item.dataset.rom ?? '').split(' ').filter(Boolean).length,
+};
+
+/** Gathers the cards into one list, best first, or puts them back in their groups as they were */
+function cardSorter(grid, items) {
+ const homes = items.map((item) => [item, item.parentElement]);
+ const group = Object.assign(document.createElement('section'), { className: 'group sorted' });
+ const list = group.appendChild(document.createElement('ul'));
+ grid.prepend(group);
+ return (rank) => {
+ if (rank) list.append(...items.toSorted((a, b) => rank(b) - rank(a)));
+ else for (const [item, home] of homes) home.append(item);
+ };
+}
+
/**
* Filters the [data-search] items in every list a FilterBar controls (cards and table rows),
* mirrored into the URL. Each select matches the item's space-separated data attribute of the
- * same name, and ended items are hidden by CSS until "Include ended support" is ticked
+ * same name, and ended items are hidden by CSS until "Include ended support" is ticked. The
+ * order select sorts the cards; the table sorts by its headers
*/
export function enhanceFilter(form) {
const [first, ...others] = form
@@ -12,6 +30,8 @@ export function enhanceFilter(form) {
const lists = [first, ...others].map(itemsOf);
const query = form.elements.q;
const selects = [...form.querySelectorAll('select')];
+ const order = form.elements.order;
+ const filters = selects.filter((select) => select !== order);
const ended = form.elements.ended;
const count = form.querySelector('[data-count]');
const empty = form.querySelector('[data-empty]');
@@ -27,7 +47,7 @@ export function enhanceFilter(form) {
const matches = (item, text) =>
item.dataset.search.includes(text) &&
- selects.every(
+ filters.every(
({ name, value }) => !value || (item.dataset[name] ?? '').split(' ').includes(value),
);
@@ -44,6 +64,14 @@ export function enhanceFilter(form) {
if (emptyEnded) emptyEnded.hidden = shown > 0 || showable.length === 0;
}
+ const sortCards = cardSorter(first, lists[0]);
+ let sortedBy = '';
+ function sort() {
+ if (order.value === sortedBy) return;
+ sortedBy = order.value;
+ sortCards(ranks[sortedBy]);
+ }
+
function save() {
const url = new URL(location.href);
const state = [
@@ -61,7 +89,9 @@ export function enhanceFilter(form) {
form.addEventListener('submit', (event) => event.preventDefault());
form.addEventListener('input', () => {
apply();
+ sort();
save();
});
apply();
+ sort();
}
diff --git a/src/styles/tokens.css b/src/styles/tokens.css
index ccb331e..e759c05 100644
--- a/src/styles/tokens.css
+++ b/src/styles/tokens.css
@@ -4,6 +4,8 @@
color-scheme: light dark;
--surface: #ffffff;
+ /* Cards and panels. White on white in light mode, a step up from the page in dark */
+ --surface-card: #ffffff;
--surface-raised: #f2f6f7;
--border: #d5dfe2;
--border-strong: #b3c2c6;
@@ -58,6 +60,7 @@
@media (prefers-color-scheme: dark) {
:root {
--surface: #0e1416;
+ --surface-card: #11191c;
--surface-raised: #151e21;
--border: #28353a;
--border-strong: #3c4d53;
diff --git a/src/styles/ui.css b/src/styles/ui.css
index 951ea9d..1ecd537 100644
--- a/src/styles/ui.css
+++ b/src/styles/ui.css
@@ -80,7 +80,7 @@
padding: 0.0625rem var(--space-xs);
border: 1px solid var(--border);
border-radius: var(--radius-pill);
- background: var(--surface);
+ background: transparent;
color: var(--text);
font-size: var(--text-xs);
font-weight: 500;
@@ -103,7 +103,7 @@
padding-inline: var(--space-md);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
- background: var(--surface);
+ background: transparent;
color: var(--text);
font-weight: 500;
line-height: 1.2;
@@ -157,7 +157,7 @@
padding: var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
- background: var(--surface);
+ background: var(--surface-card);
}
/* A card whose title link covers the whole card, so the card is one big target */
diff --git a/tests/facts.test.js b/tests/facts.test.js
index 0828384..ed7cbc6 100644
--- a/tests/facts.test.js
+++ b/tests/facts.test.js
@@ -4,8 +4,11 @@ import { loadData } from '../src/lib/data/load.js';
import { buildModel } from '../src/lib/data/model.js';
import {
buildFact,
+ channelText,
everywhereCells,
+ firmwareNeed,
knownFeatures,
+ maintainerOf,
needsOf,
relockFact,
romRelock,
@@ -99,13 +102,17 @@ test('builds carry a readable date, their patch month, and say so once they are
});
test('relock and install needs only say what the ROM says', () => {
- assert.equal(relockFact(tidyRow('2026-02-01')).text, 'Bootloader relocks');
+ const { name, text } = relockFact(tidyRow('2026-02-01'));
+ assert.deepEqual([name, text], ['Bootloader relock', 'Supported']);
+ assert.equal(relockFact({ relock: 'no' }).text, 'Not supported');
assert.equal(relockFact({ relock: 'unknown' }), undefined);
assert.equal(relockFact({}), undefined);
assert.deepEqual(needsOf({ firmware: '16', channel: 'beta' }), [
- 'Stock Android 16 first',
+ 'Install stock Android 16 first',
'Beta build',
]);
+ assert.equal(firmwareNeed({ firmware: '14/15/16' }), 'Install stock Android 14, 15 or 16 first');
+ assert.equal(channelText({ channel: 'community' }), 'Community build');
assert.deepEqual(needsOf({ channel: 'stable' }), []);
});
@@ -116,3 +123,10 @@ test("a ROM's relock answer gives way when its own device pages say otherwise",
assert.equal(romRelock(rom), 'On some devices');
assert.equal(romRelock({ security: { relockable_bootloader: true }, support: [] }), 'Yes');
});
+
+test('a maintainer is a name, with a link when the ROM gives one', () => {
+ assert.deepEqual(maintainerOf({ maintainer: 'Joey' }), { name: 'Joey' });
+ const linked = { name: 'Joey', link: 'https://github.com/joeyhuab' };
+ assert.deepEqual(maintainerOf({ maintainer: linked }), linked);
+ assert.equal(maintainerOf({}), undefined);
+});
diff --git a/tests/filters.test.js b/tests/filters.test.js
new file mode 100644
index 0000000..25b51f1
--- /dev/null
+++ b/tests/filters.test.js
@@ -0,0 +1,45 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { deviceFilters, filterData } from '../src/lib/filters.js';
+
+const acme = { key: 'acme', name: 'Acme' };
+const tidyos = { key: 'tidyos', name: 'TidyOS' };
+const device = (key, extra) => ({
+ key,
+ title: `Acme ${key}`,
+ codenames: [key],
+ brand: acme,
+ bootloader: { unlock: 'yes' },
+ support: [{ rom: tidyos, active: true }],
+ activeCount: 1,
+ ...extra,
+});
+
+test('devices carry what the dropdowns and the sort match on', () => {
+ const data = filterData(device('rocket', { hardware: ['5g', 'nfc'], released: 2024 }));
+ assert.equal(data['data-rom'], 'tidyos');
+ assert.equal(data['data-type'], 'phone');
+ assert.equal(data['data-hardware'], '5g nfc');
+ assert.equal(data['data-released'], 2024);
+ assert.equal(data['data-ended'], undefined);
+});
+
+test('dropdowns only offer choices that narrow the list down', () => {
+ const devices = [
+ device('rocket', { hardware: ['esim', 'nfc'] }),
+ device('comet', { hardware: ['nfc'], type: 'tablet' }),
+ ];
+ assert.deepEqual(
+ deviceFilters(devices, [tidyos]).map(({ name, options }) => [name, options]),
+ [
+ [
+ 'type',
+ [
+ ['phone', 'Phones'],
+ ['tablet', 'Tablets'],
+ ],
+ ],
+ ['hardware', [['esim', 'With eSIM']]],
+ ],
+ );
+});
diff --git a/tests/import/evolutionx.test.js b/tests/import/evolutionx.test.js
index c027199..2810bb5 100644
--- a/tests/import/evolutionx.test.js
+++ b/tests/import/evolutionx.test.js
@@ -30,7 +30,7 @@ test('a maintained device gets android from its branch, latest and the forum lin
assert.deepEqual(row, {
status: 'active',
android: 17,
- maintainer: 'Joey',
+ maintainer: { name: 'Joey', link: 'https://github.com/joeyhuab' },
latest: { version: '12.2', date: '2026-09-09' },
source: 'https://evolution-x.org/device/marble',
install: 'https://xdaforums.com/t/rom-17-marble-official-evolution-x-07-20-26.4709959/',
diff --git a/tests/report.test.js b/tests/report.test.js
index 5fea7c3..3c7d817 100644
--- a/tests/report.test.js
+++ b/tests/report.test.js
@@ -7,6 +7,7 @@ import { join } from 'node:path';
import { parse } from 'yaml';
import { loadData } from '../src/lib/data/load.js';
import { check } from '../src/lib/data/check.js';
+import { reportUrl } from '../src/lib/table.js';
const ISSUE_URL = 'https://github.com/NotAFlightRisk/android-rom-compat/issues/7';
const today = new Date().toISOString().slice(0, 10);
@@ -68,3 +69,19 @@ test('partial or broken without a note fails, naming only the feature missing on
assert.match(output.error, /VoLTE is partial, so add a line to Notes/);
assert.doesNotMatch(output.error, /NFC/);
});
+
+test("report links fill in the form's own fields", () => {
+ const form = parse(readFileSync('.github/ISSUE_TEMPLATE/report-feature.yml', 'utf8'));
+ const ids = new Set(form.body.map((field) => field.id));
+ const device = { key: 'rocket', title: 'Acme Rocket' };
+ const link = new URL(reportUrl(device, { rom: { name: 'TidyOS' }, android: 16 }));
+ const { template, title, ...fields } = Object.fromEntries(link.searchParams);
+ assert.equal(template, 'report-feature.yml');
+ assert.equal(title, '[Report]: Acme Rocket (rocket) on TidyOS');
+ assert.deepEqual(fields, { device: 'rocket', rom: 'TidyOS', android: '16' });
+ assert.ok(Object.keys(fields).every((id) => ids.has(id)));
+ assert.deepEqual(
+ [...new URL(reportUrl(device)).searchParams.keys()],
+ ['template', 'title', 'device'],
+ );
+});