From 16dca64834a041abde4a8fd771b617d72570a0d3 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Tue, 13 Oct 2026 15:38:45 +0200 Subject: [PATCH] =?UTF-8?q?Website:=20machine=20estimator=20v2=20=E2=80=94?= =?UTF-8?q?=20fold-out=20categories=20and=20the=20memory=20bar=20(still=20?= =?UTF-8?q?unlisted,=20R-997)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each category is a native
, closed at first, with an 'n selected' count; a preset opens what it ticks. A sticky memory bar (0-64 GB, marks 8/16/24/32/48/64+): solid fill = smallest box memory where the selection fits well, thin mark = where it fits, but tight; one line names the box to buy. minBoxGb() inverts guestMib() by bisection on it, so the rule is still written once. Box sizes 12/24/48/64 added; tests cover seven sizes and the inverse (red-proofed); SIZING.md table gains 24 GB. --- .../audits/sizing-2026-10-13/SIZING.md | 14 +- documentation/backlog/OPEN-ITEMS.md | 2 +- scripts/CHANGELOG.md | 9 ++ scripts/build_sizing_web.py | 6 +- scripts/test_build_sizing_web.py | 66 +++++++-- website/CHANGELOG.md | 18 ++- website/assets/sizing-data.json | 15 +- website/assets/sizing.css | 68 +++++---- website/assets/sizing.js | 131 +++++++++++++----- website/en/sizing.html | 29 ++-- website/gepigeny.html | 29 ++-- 11 files changed, 277 insertions(+), 110 deletions(-) diff --git a/documentation/audits/sizing-2026-10-13/SIZING.md b/documentation/audits/sizing-2026-10-13/SIZING.md index 4411798e..c4f88323 100644 --- a/documentation/audits/sizing-2026-10-13/SIZING.md +++ b/documentation/audits/sizing-2026-10-13/SIZING.md @@ -167,13 +167,13 @@ peak × 1.5 — which Immich alone does not meet on 8 GB ((470 + 3,061) × 1.5 **Fits well:** peak × 1.5 ≤ guest · **fits, but tight:** peak ≤ guest < peak × 1.5 — works, but a large photo import or a backup at the same time can slow it down · **does not fit:** guest < peak. Peak = 470 MB platform + the apps' peaks summed. - -| Selection | Peak | 8 GB | 16 GB | 32 GB | -|---|---|---|---|---| -| Household set | 5.4 GB | does not fit | fits well | fits well | -| Household set without Immich | 2.4 GB | fits well | fits well | fits well | -| Immich alone | 3.4 GB | fits, but tight | fits well | fits well | -| All 62 apps | 21.3 GB | does not fit | does not fit | fits, but tight | + +| Selection | Peak | 8 GB | 16 GB | 24 GB | 32 GB | +|---|---|---|---|---|---| +| Household set | 5.4 GB | does not fit | fits well | fits well | fits well | +| Household set without Immich | 2.4 GB | fits well | fits well | fits well | fits well | +| Immich alone | 3.4 GB | fits, but tight | fits well | fits well | fits well | +| All 62 apps | 21.3 GB | does not fit | does not fit | does not fit | fits, but tight | - **8 GB:** the household set without Immich fits well; Immich alone fits, but tight; the whole household set does not fit. diff --git a/documentation/backlog/OPEN-ITEMS.md b/documentation/backlog/OPEN-ITEMS.md index ad9bcc21..8781ea97 100644 --- a/documentation/backlog/OPEN-ITEMS.md +++ b/documentation/backlog/OPEN-ITEMS.md @@ -121,7 +121,7 @@ stopping line that lies. | **R-516** | Install & onboarding | P3 | **[P3-LOW] English a customer meets on a fresh box and its apps' first screens — enumerated by the big night.** MEASURED 2026-09-14 (BIGNIGHT, VM 333, ISO 1.27.1, controller 0.242.0). Felhom-owned: (1) the dashboard menu item **„Debug"**; (2) the dashboard CPU tile **„Load: 0.29 / 0.39 / 0.37"**; (3) the launcher tile **„Filebrowser"** opens a login in English with no Felhom text (R-513); (4) the storage page mixes formal „Adjon hozzá / Csatlakoztasson" with the product's „te". App first screens a household meets before any Felhom text helps: (5) **Uptime Kuma 2.4 opens on „Which database would you like to use?"** (SQLite / Embedded MariaDB, „Next") — the app card's „Első lépések" does not mention it; (6) PrivateBin, Gokapi, AdventureLog and FileBrowser UIs are English (the apps' own). Already rows: the Proxmox installer screens (R-495, answered by the guide), `wiki.DOMAIN` (R-498). **Fix shape:** rename „Debug"/„Load" (controller); add the Uptime Kuma database step to its card, or pre-seed `db-config.json` for SQLite in the template (catalog). **Added by F4 (20:04:54Z):** (7) the storage page prints the disconnect time as a raw ISO UTC string „Leválasztva: 2026-09-14T19:58:02Z"; (8) the „Meghajtó leválasztva" banner appears twice on every page; (9) „4 telepített alkalmazás nem fut — nézze meg a rendszermonitort" uses the formal form. **Added by F7 (20:50–21:00Z, system disk at 95 %):** (10) a banner on every page in English, „**SSD disk usage high: 90%**"; (11) the dashboard tile reads „Rendszer (/) 61.8 GB / 68.7 GB (**90%**)" while `df` reports **95 %** (reserved blocks ignored), and „(/)" labels the data volume `/mnt/sys_drive`; the deploy page says nothing about free disk. **Added by the i18n spike (2026-09-17, controller v0.247.0):** (12) **six formal („ön") forms in the converted dashboard copy** — „Olvassa be telefonnal" (launcher QR hint), „Biztosan kikapcsolja a megosztást?" (launcher), „Ha újratelepíti … importálnia kell" (the layout's remove-app modal), „Kérjük, vegye fel a kapcsolatot" (backups empty state). They were NOT fixed — a localisation release may not change Hungarian bytes — and are now COUNTED by `controller/scripts/i18n_missing_gate.py` (`HU_FORMAL_CEILING` = 6, a ratchet: a new one convicts, fixing one lowers it). The inventory (`audits/I18N-INVENTORY-2026-09-17.md`) is the list this row closes against in localisation slice 6 (R-561). **Extended 2026-09-17 by slice 1 (controller v0.248.0–v0.250.0):** the converted copy now counts **16** formal forms (`HU_FORMAL_CEILING` 6 → 10 → 12 → 16, each raise stated, Hungarian unchanged by rule) — and the count is an UNDER-count: the gate's stem list sees 4 on the release C pages (storage, drive wizards, debug), while a wider list („írja be”, „adja meg”, „adjon hozzá”, „válassza ki”, „biztosan eltávolítja”, „engedélyezze”, „hozzon létre”, „kattintson”) finds at least **22** keys there alone (login „Adja meg a jelszavát”, the NAS guide, the storage confirms). Widen the stems when this row is worked; the ceiling rises with them. **NARROWED 2026-09-20 by localisation slice 6's walk, item by item** (`audits/i18n-slice6-2026-09-20/R-516-item-by-item.md`): item 2 FIXED (slice 1), items 3 and 6 APP-OWNED (and for an English household item 6 is an advantage), item 11 is a wrong NUMBER rather than a language and needs its own row, item 1 is correct English for an English reader and still open for a Hungarian one. **Items 4, 7, 8, 9 and 10 are NOT DECIDABLE FROM AN ENGLISH WALK** - they are about Hungarian copy quality, a disconnected drive and a full disk, none of which this walk had. **What this row is now waiting for is a HUNGARIAN walk on a box with a second drive**, not another English one; saying it closed would be the kind of closure that makes a register stop meaning anything. **-- 2026-10-08:** the recovery screen's own answers are Go literals in Hungarian only — 14 `renderRecovery` messages in `controller/internal/web/recovery_handlers.go` (seen: the safe default „A művelet nem fejeződött be…" rendered on an English page in a red-proof run). Today's new R-304 sentence is a bundle key in both languages; the 14 old ones are not. | **NARROWED 2026-09-20 - rank P3-LOW; owner: CC; needs a Hungarian walk** **2026-10-05 (burn-down night): items 1 and 4 FIXED on controller `main`** (`d7efa7b`): the menu says „Hibakeresés” (English „Debug”); the storage pages use the te-form (formal-form ceiling 17 → 14; 116 parity fixtures changed by exactly those bytes). Item 2 was already fixed. **Left:** items 5–6 (apps' own screens) and the rest. **2026-10-06: items 1 and 4 DELIVERED** (controller v0.298.0). **2026-10-06 (burn-down night, later): items 7–10 and 12 FIXED on controller `main`** (`e4774e6`, `2e5ef7e`): the disconnect time in local time; one banner per unplugged drive; „nézd meg”; the disk/memory/CPU/temperature banners in the household's language (`health.*` keys; the wire text to the hub unchanged and pinned); the last eleven counted formal forms are te-form (ceiling 14 → 0; 119 parity fixtures changed by exactly those bytes). **Left:** items 5–6 (apps' own screens); about 20 formal forms the gate does not count yet („írja be”, „adja meg”, „hozzon létre”, …) on the debug, storage, network-storage and security pages. Item 11 moved to its own row **R-889**. **2026-10-06 02:40: the bundle has NO formal form left** (controller `4c3c203`, unreleased): 49 sentences in the te-form; the gate's stem list widened (it counted 61 on the previous bundle, 0 now) with decoys and one listed third-person exception. **Left (narrowed):** Felhom-owned formal forms in Go and template literals the gate cannot read yet — the network-storage attach errors, escrow and share handlers, the single-copy notice, a settings refusal, the SMART mail, a fill-watch „Kérjük”, and the setup wizard pages; each moves into the bundle in the te-form. Items 5–6 are the apps' own screens (not ours). **2026-10-06: items 7–10, 12 and the bundle's 49 formal forms DELIVERED** in controller v0.299.0. **2026-10-06 night: the Go-literal leftover moved** (controller main `3d6ba28`, unreleased): 22 Go-literal messages — escrow handler (6), share password page (2), export upload (1), network-storage uid/remove/attach failures (13) — are bundle keys (`api.escrow.*`, `api.share.*`, `api.export.*`, `api.netstorage.*`) in the te-form with English (14 were formal); a detached attach failure renders in the reader's language; the NAS refusal says „Válassz". `TestR516_GoLiteralFormalFormsAreGone` (source scan + positive control), `TestR516_NetAddFailureFollowsTheReader`; red-proofs `audits/night-burndown-2026-10-06/ctrl/R-516-red*.txt`. **Left:** the setup wizard and recovery-info.txt (R-554); the SMART and fill-watch texts (the hub event's message — wire text, needs the household-copy producer shape); items 5–6 (apps' own screens). | — | — | CC | | **R-991** | Install & onboarding | P3 | **Nothing a buyer can read says which machines Felhom installs on, and a quarter of the forum beginners own only machines it cannot install on.** FOUND 2026-10-12 (forum research, `audits/forum-research-2026-10-12/FELHOM.md` §2). Searched `documentation/architecture/`, `website/` (`gyik`, `technologiak`, `letoltes`) and `scripts/felhom-host-install.sh`: no CPU-architecture, RAM, disk or network floor is written anywhere. The download page says only that the installer is built on Proxmox VE 9.2 — x86-64 only (inferred from Proxmox, not stated on the page). Of the 33 forum beginners who name what they own, 8 own only a Synology/QNAP/other NAS box, a router with a USB disk or a Raspberry Pi. The selfh.st survey reading reached the same gap independently (R-989, its recommendation 1). **Needs (operator):** the floor itself (RAM and disk minimum; whether thin clients and old laptops with 4–8 GB are accepted) — then one short section on the download page and in the FAQ. Not changed here: the research task forbade website edits. | **WAITING-ON-OPERATOR** — filed 2026-10-12 | the operator naming the floor | Operator: name the minimum (RAM, disk, x86-64); then CC writes the section on `letoltes.html` and `gyik.html` (both languages). If nothing is done: a buyer with a Pi or a Synology downloads an ISO that cannot run | operator | | **R-993** | Install & onboarding | P3 | **Operator: approve the minimum machine in `documentation/audits/sizing-2026-10-13/SIZING.md` §5** (the input R-991's hardware page needs). MEASURED 2026-10-13 on the N100 demo box (61 apps deployed and removed through the product, 15 min at rest each; heavy apps with one real action). Proposal: **minimum** x86-64, 4 cores (N100 class), **8 GB**, 256 GB SSD; **recommended 16 GB**, 512 GB SSD; out: ARM / Raspberry Pi, Synology-QNAP, routers, cloud VPS. Worked from the floor (2.5 GB for the whole box with no apps) + Immich, Jellyfin, Nextcloud, Home Assistant, Vaultwarden, Paperless-ngx at their peaks (5.0 GB) + 50 % headroom ≈ 12.3 GB. 8 GB runs that set without Immich; 16 GB runs all of it twice over. Data for the estimator: `sizing.json`. **2026-10-13 (estimator session) — the installer accepts a host BELOW this minimum:** `autosize_guest_caps` has no floor on the host, only on the guest; on a 4 GB box (MemTotal ≈ 3.5 GiB) the guest gets host − 1 GB ≈ 2.4 GB and Proxmox keeps ≈ 1 GB, where it used ≈ 2.0 GB on the N100 (SIZING.md §1). Whatever minimum is approved, nothing enforces it at install today. The three-verdict wording of §5 now matches the estimator page (`/gepigeny`, R-997). | **WAITING-ON-OPERATOR** — filed 2026-10-13 | the operator's decision | Operator: approve or change §5. If nothing is done: no hardware page; a buyer with a Raspberry Pi can still download the installer (R-991 stays open) | operator | -| **R-997** | Install & onboarding | P3 | **Operator: try the machine estimator at https://felhom.eu/gepigeny (English: /en/sizing) and say „list it” or what to change.** BUILT 2026-10-13: a household ticks apps and sees, for an 8, 16 and 32 GB box, fits well / fits, but tight / does not fit, the totals, and which apps make the CPU work hard for a short time; numbers from the 2026-10-13 N100 measurement (`documentation/audits/sizing-2026-10-13/`). LIVE but UNLISTED: noindex, `Disallow` in robots.txt, not in the sitemap, linked from nowhere — `site_gates.py` gate 25 keeps it that way. It names no minimum machine (R-993) and gives no power or noise figure. Its JavaScript was never run by CC (no browser or JS engine on DooPlex): the operator's try is its first real run. **To list it** (one website task, after the power readings): move the pair from `UNLISTED` to `TWINS`, drop noindex and the two Disallow lines, add both URLs to the sitemap, link it from the menu and the FAQ. | **WAITING-ON-OPERATOR** — filed 2026-10-13 | the operator's try | Operator: open the link, tick a few apps, then say „list it” or what to change. If nothing is done: the page stays unlisted and nobody finds it | operator | +| **R-997** | Install & onboarding | P3 | **Operator: try the machine estimator at https://felhom.eu/gepigeny (English: /en/sizing) and say „list it” or what to change.** BUILT 2026-10-13: a household ticks apps and sees, for an 8, 16 and 32 GB box, fits well / fits, but tight / does not fit, the totals, and which apps make the CPU work hard for a short time; numbers from the 2026-10-13 N100 measurement (`documentation/audits/sizing-2026-10-13/`). LIVE but UNLISTED: noindex, `Disallow` in robots.txt, not in the sitemap, linked from nowhere — `site_gates.py` gate 25 keeps it that way. It names no minimum machine (R-993) and gives no power or noise figure. Its JavaScript was never run by CC (no browser or JS engine on DooPlex): the operator's try is its first real run. **To list it** (one website task, after the power readings): move the pair from `UNLISTED` to `TWINS`, drop noindex and the two Disallow lines, add both URLs to the sitemap, link it from the menu and the FAQ. **v2 live 2026-10-13 afternoon:** fold-out categories and a memory bar (solid = fits well, thin mark = tight) with one line naming the box to buy; the reviewer's v1 browser check (Playwright: console clean, 390 px without sideways scroll, only felhom.eu + stats.felhom.eu) asked for the shorter page. | **WAITING-ON-OPERATOR** — filed 2026-10-13 | the operator's try | Operator: open the link, tick a few apps, then say „list it” or what to change. If nothing is done: the page stays unlisted and nobody finds it | operator | | **R-494** | Install & onboarding | P4 | **NARROWED 2026-09-14 by operator ruling → [P3-LOW] the hub COULD create the tunnel at customer creation, for a domain already on Cloudflare. Not blocking: every customer has their own domain and the operator creates the tunnel per day-0 A.1 (`architecture/01-topology-and-trust.md`).** *Original finding, kept:* **[P1-HIGH] A new customer's dashboard has NO reachable address unless the operator hand-makes a Cloudflare tunnel — the link in the setup-code mail is dead.** MEASURED 2026-09-14 on a fresh install from the public ISO (drill intervention **I1**): the claim mail points at `https://felhom.drill0242.felhom.eu`; that name has **no A and no AAAA** record (`dig @1.1.1.1`, control `felhom.enkisfelhom.hu` resolves); the hub has **no tunnel- or DNS-creation code** (`hub/internal/cloudflare/` holds only geo-rule removal; `cf_tunnel_token` is a pasted, optional form field, `configs.go:1478`) — day-0 runbook A.1 makes it a manual Cloudflare-dashboard step that nothing on the customer-create page asks for; the box's own split-horizon resolver on the appliance LAN IP answered `google.com` but not the dashboard name at 13:27:39Z; the agent applied the record at **13:27:44Z** (`lanresolver: applied split-horizon record … ip=192.168.0.158`, 3 m 46 s after the controller started), so the box CAN answer the name — **but only to a device that uses the box as its DNS server, and no document, screen or mail tells a household to do that**; the router and the installer-offered DNS answer nothing. The page was reachable only at the guest's LAN address with the name forced (`curl --resolve …:443:192.168.0.158`). **A volunteer could not have done that.** **What it needs:** an operator ruling — the hub creates the tunnel and DNS at customer creation, or the product gives a household a LAN address that works with no DNS change. | **READY — rank P3-LOW; owner: CC** **Re-ranked 2026-10-03: P3→P4: operator-side automation; the operator creates the tunnel by hand per the day-0 runbook.** | — | — | CC | | **R-504** | Install & onboarding | P4 | **[P3-LOW] `iso.felhom.eu` cannot show an index page on its own — its root returns 404, and the download page lives on the website instead.** MEASURED 2026-09-14: `https://iso.felhom.eu/` and `/index.html` → 404; only named objects answer. The host is an R2 bucket behind a custom domain; whether R2 would serve an uploaded `index.html` at `/` was **not measured** (uploading anything to the public bucket is a publication). The ISO v1.27.0 task puts the Hungarian download page at `felhom.eu/letoltes` (published with the ISO, after the operator's yes). **Remaining:** a redirect from `iso.felhom.eu/` to that page needs a Cloudflare rule the session has no credential for. | **WAITING-ON-OPERATOR — rank P3-LOW; owner: operator (Cloudflare rule)** **Re-ranked 2026-10-03: P3→P4: households are sent to the website's download page; the bare address is cosmetic.** | — | — | operator | diff --git a/scripts/CHANGELOG.md b/scripts/CHANGELOG.md index 4c8cfe08..0b93045c 100644 --- a/scripts/CHANGELOG.md +++ b/scripts/CHANGELOG.md @@ -1,3 +1,12 @@ +## 2026-10-13 (afternoon) — estimator v2: `build_sizing_web.py` box sizes, tests for the inverse + +- `build_sizing_web.py`: `boxes_gb` = 8, 12, 16, 24, 32, 48, 64; new `bar_marks_gb` and `bar_max_gb`. +- `test_build_sizing_web.py` (8 tests): the four cases × seven sizes, with the recommended („fits well”) and the + smallest „tight” size each; the inverse agrees with the forward rule (the guest at the answer holds the need, one + step smaller does not); SIZING.md's table is read by its header and must carry the 24 GB column; `sizing.js` may + hold only 0, 1, 2, 100 and 1024. Red-proofed: a wrong 24 GB cell, a missing 24 GB column, a hard-coded 64 in the + JS, and an inverse 1 GB too big each convicted. + ## 2026-10-13 — `build_sizing_web.py` + `site_gates.py` UNLISTED pairs and gate 25 - **`build_sizing_web.py`** builds `website/assets/sizing-data.json` (the machine estimator's data) from diff --git a/scripts/build_sizing_web.py b/scripts/build_sizing_web.py index 51771c1b..64bc5a9c 100644 --- a/scripts/build_sizing_web.py +++ b/scripts/build_sizing_web.py @@ -33,7 +33,9 @@ HEADROOM = 1.5 # 15,772 MiB (measured 2026-10-13), so a box of N GB is taken as N*1024 - 612 MiB. GUEST_RULE = {"reserve_mib": 4096, "max_gap_mib": 2048, "min_mib": 4096, "ceiling_gap_mib": 1024, "memtotal_shortfall_mib": 612} -BOXES_GB = [8, 16, 32] +BOXES_GB = [8, 12, 16, 24, 32, 48, 64] # the common sizes a recommendation names (v2: 12, 24, 48, 64 added) +BAR_MARKS_GB = [8, 16, 24, 32, 48, 64] # the memory bar's marks; the last one reads "64+" +BAR_MAX_GB = 64 # the bar's linear scale is 0..BAR_MAX_GB BUILTIN = {"filebrowser"} # One short line per medium / heavy-in-bursts app (the 9 of 2026-10-13). sizing.json's English notes, tidied. @@ -115,6 +117,8 @@ def build(): headroom=HEADROOM, guest_rule=GUEST_RULE, boxes_gb=BOXES_GB, + bar_marks_gb=BAR_MARKS_GB, + bar_max_gb=BAR_MAX_GB, categories=[dict(id=h[0], id_en=e[0], name_hu=h[1], name_en=e[1]) for h, e in zip(hu_cats, en_cats)], apps=apps, ) diff --git a/scripts/test_build_sizing_web.py b/scripts/test_build_sizing_web.py index 6d2e3836..dd85088a 100644 --- a/scripts/test_build_sizing_web.py +++ b/scripts/test_build_sizing_web.py @@ -36,6 +36,19 @@ def guest_mib(d, gb): # mirrors sizing.js guestMib() return mem +def min_box_gb(d, need): # mirrors sizing.js minBoxGb(): a bisection on guest_mib, never a 2nd rule + lo, hi = 0.0, 1.0 + while guest_mib(d, hi) < need: + hi *= 2 + while hi - lo > 1 / 1024: + mid = (lo + hi) / 2 + if guest_mib(d, mid) >= need: + hi = mid + else: + lo = mid + return hi + + def estimate(d, slugs): # mirrors sizing.js estimate() by = {a["slug"]: a for a in d["apps"]} peak = d["platform_mb"] + sum(by[s]["peak_mb"] for s in slugs if not by[s].get("builtin")) @@ -46,6 +59,10 @@ def estimate(d, slugs): # mirrors sizing.js estimate() return peak, out +def first(d, verdicts, want): # mirrors recommend_gb / tight_box_gb in sizing.js + return next((gb for gb, v in zip(d["boxes_gb"], verdicts) if v == want), None) + + class SizingWeb(unittest.TestCase): @classmethod def setUpClass(cls): @@ -61,19 +78,39 @@ class SizingWeb(unittest.TestCase): self.assertEqual([a["slug"] for a in self.d["apps"] if a.get("builtin")], ["filebrowser"]) def test_guest_sizes_follow_the_installer(self): - self.assertEqual([guest_mib(self.d, gb) for gb in (8, 16, 32)], [4096, 11676, 28060]) + self.assertEqual(self.d["boxes_gb"], [8, 12, 16, 24, 32, 48, 64]) + self.assertEqual([guest_mib(self.d, gb) for gb in self.d["boxes_gb"]], + [4096, 7580, 11676, 19868, 28060, 44444, 60828]) self.assertEqual(guest_mib(self.d, 4), 4 * 1024 - 612 - 1024) # a 4 GB host: the ceiling, not 4 GB def test_the_four_cases(self): + # columns: 8, 12, 16, 24, 32, 48, 64 GB; then the recommended ("fits well") and the smallest tight size + W, T, N = "well", "tight", "no" cases = { - "household": (HOUSEHOLD, ["no", "well", "well"]), - "household without immich": ([s for s in HOUSEHOLD if s != "immich"], ["well", "well", "well"]), - "immich alone": (["immich"], ["tight", "well", "well"]), - "all 62": (self.all, ["no", "no", "tight"]), - "nothing": ([], ["well", "well", "well"]), + "household": (HOUSEHOLD, [N, T, W, W, W, W, W], 16, 12), + "household without immich": ([s for s in HOUSEHOLD if s != "immich"], [W, W, W, W, W, W, W], 8, None), + "immich alone": (["immich"], [T, W, W, W, W, W, W], 12, 8), + "all 62": (self.all, [N, N, N, N, T, W, W], 48, 32), # 24 GB: guest 19,868 < peak 21,783 + "nothing": ([], [W] * 7, 8, None), } - for name, (sel, want) in cases.items(): - self.assertEqual(estimate(self.d, sel)[1], want, name) + for name, (sel, want, rec, tight) in cases.items(): + peak, verdicts = estimate(self.d, sel) + self.assertEqual(verdicts, want, name) + self.assertEqual(first(self.d, verdicts, "well"), rec, name + " recommended") + self.assertEqual(first(self.d, verdicts, "tight"), tight, name + " smallest tight") + + def test_inverse_agrees_with_the_forward_rule(self): + cases = [HOUSEHOLD, [s for s in HOUSEHOLD if s != "immich"], ["immich"], self.all, []] + for sel in cases: + peak, _ = estimate(self.d, sel) + for need in (peak * self.d["headroom"], peak): + gb = min_box_gb(self.d, need) + self.assertGreaterEqual(guest_mib(self.d, gb), need) # it fits at the answer + self.assertLess(guest_mib(self.d, gb - 2 / 1024), need) # and not one step smaller + # the recommendation is the first listed size at or above the fill + rec = next((b for b in self.d["boxes_gb"] if guest_mib(self.d, b) >= need), None) + if rec is not None: + self.assertGreaterEqual(rec, gb - 1 / 1024) def test_sizing_md_table_agrees_with_the_rule(self): with io.open(SIZING_MD, encoding="utf-8") as f: @@ -83,7 +120,9 @@ class SizingWeb(unittest.TestCase): rows = {} for line in block.group(1).splitlines(): cells = [c.strip() for c in line.strip().strip("|").split("|")] - if len(cells) == 5 and cells[0] not in ("Selection", "---"): + if cells and cells[0] == "Selection": + cols = [int(c.split()[0]) for c in cells[2:]] + elif len(cells) > 2 and not cells[0].startswith("---"): rows[cells[0]] = cells sel = {"Household set": HOUSEHOLD, "Household set without Immich": [s for s in HOUSEHOLD if s != "immich"], "Immich alone": ["immich"], "All 62 apps": self.all} @@ -91,7 +130,9 @@ class SizingWeb(unittest.TestCase): for name, slugs in sel.items(): peak, verdicts = estimate(self.d, slugs) self.assertEqual(rows[name][1], "%.1f GB" % (peak / 1024), name + " peak") - self.assertEqual(rows[name][2:], [WORDS[v] for v in verdicts], name) + by = dict(zip(self.d["boxes_gb"], verdicts)) + self.assertIn(24, cols, "SIZING.md's table lacks the 24 GB column") + self.assertEqual(rows[name][2:], [WORDS[by[c]] for c in cols], name) def test_js_has_the_rule_and_none_of_its_numbers(self): js = io.open(JS, encoding="utf-8").read() @@ -103,7 +144,10 @@ class SizingWeb(unittest.TestCase): code = re.sub(r"(?m)//.*$", "", code) code = re.sub(r"'(?:[^'\\]|\\.)*'", "''", code) nums = set(re.findall(r"(?= needMib"): + self.assertIn(need, js) def test_data_has_no_peak_below_typical(self): for a in self.d["apps"]: diff --git a/website/CHANGELOG.md b/website/CHANGELOG.md index a893ad54..aee17f62 100644 --- a/website/CHANGELOG.md +++ b/website/CHANGELOG.md @@ -1,4 +1,20 @@ -## 2026-10-13 — the machine estimator, live but unlisted (`/gepigeny`, `/en/sizing`) +## 2026-10-13 (afternoon) — machine estimator v2: fold-out categories and the memory bar + +The page was 4,592 px tall on a phone (the reviewer's browser check of v1). Still unlisted (R-997). + +- **Fold-out categories:** one native `
` per category, all closed at first, dressed like the FAQ's items; + the summary shows „n kiválasztva” / „n selected”. A preset opens every category it ticks something in. +- **The memory bar** above the list, sticky (under the phone's top bar): a 0–64 GB scale with marks at 8, 16, 24, + 32, 48 and 64+ GB. Solid fill = the smallest box memory where the selection fits well; a thin mark = where it fits, + but tight. One line under it names the box to buy (the smallest of 8, 12, 16, 24, 32, 48, 64 GB that fits well) + and the size where it is only tight. `role="meter"` with the needed GB as its value and text. A legend says which + mark means what, so nothing is said by colour alone. +- The three verdict cards are gone; the totals line, the „tight” explanation and the CPU lines stay under the bar. +- **`sizing.js`:** `minBoxGb()` beside `guestMib()` — a bisection on `guestMib()` itself, so the installer's rule + is still written once. Box sizes 12, 24, 48, 64 GB added in the data file (`boxes_gb`, `bar_marks_gb`, `bar_max_gb`). + Cache versions: `sizing.css`, `sizing.js` and the data file `?v=2`. + +## 2026-10-13 — the machine estimator, live but unlisted (`/gepigeny`, `/en/sizing`) A household ticks the apps it wants and sees, for an 8, 16 and 32 GB box: **fits well**, **fits, but tight** or **does not fit** — in text, an icon and a colour, never colour alone — the typical and peak totals, and the diff --git a/website/assets/sizing-data.json b/website/assets/sizing-data.json index 9f018d49..ee2dfbd2 100644 --- a/website/assets/sizing-data.json +++ b/website/assets/sizing-data.json @@ -18,9 +18,22 @@ }, "boxes_gb": [ 8, + 12, 16, - 32 + 24, + 32, + 48, + 64 ], + "bar_marks_gb": [ + 8, + 16, + 24, + 32, + 48, + 64 + ], + "bar_max_gb": 64, "categories": [ { "id": "fajlkezeles", diff --git a/website/assets/sizing.css b/website/assets/sizing.css index 4017887a..47d4137b 100644 --- a/website/assets/sizing.css +++ b/website/assets/sizing.css @@ -1,41 +1,57 @@ /* sizing.css — the machine estimator only (/gepigeny, /en/sizing). Every selector starts with .sizer, - so nothing here reaches the shell or another page. Tokens from site.css :root; no raw colours. */ + so nothing here reaches the shell or another page. Tokens from site.css :root; no raw colours. + v2 (2026-10-13): fold-out categories in the FAQ's look, and the memory bar (sticky on a phone). */ .sizer[hidden] { display: none; } -.sizer-presets { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0 0 1.5rem; } -.sizer-layout { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); gap: 2.5rem; align-items: start; } -.sizer-group { border: 0; border-top: 1px solid var(--line); margin: 0 0 1.25rem; padding: 0.75rem 0 0; min-width: 0; } -.sizer-group legend { color: var(--text-1); font-weight: 600; padding: 0 0.5rem 0 0; } +.sizer-presets { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0 0 1rem; } + +/* the memory bar — kept short: track, marks, one line, a legend (≈ 130 px, under a quarter of 390 x 800) */ +.sizer-sticky { position: sticky; top: 0; z-index: 5; background: var(--bg-0); border-bottom: 1px solid var(--line); padding: 0.75rem 0 0.6rem; margin: 0 0 1rem; } +.sizer-meter { padding: 0 0 1.4rem; } +.sizer-track { position: relative; height: 12px; background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius); } +.sizer-fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: var(--blue); border-radius: var(--radius); transition: width 0.25s ease; } +.sizer-tightmark { position: absolute; top: -4px; bottom: -4px; width: 2px; margin-left: -1px; background: var(--warn); transition: left 0.25s ease; } +.sizer-scale { position: relative; height: 1rem; margin-top: 0.3rem; } +.sizer-mark { position: absolute; transform: translateX(-100%); padding-right: 2px; border-right: 1px solid var(--line); font-family: var(--font-data); font-size: 0.72rem; color: var(--text-3); white-space: nowrap; line-height: 1rem; } +.sizer-line { margin: 0; color: var(--text-1); font-weight: 600; line-height: 1.45; } +.sizer-legend { margin: 0.2rem 0 0; color: var(--text-3); font-size: 0.78rem; } + +.sizer-result { margin: 0 0 1.25rem; } +.sizer-totals { color: var(--text-2); margin: 0; line-height: 1.7; } +.sizer-num { font-family: var(--font-data); color: var(--text-1); font-weight: 600; } +.sizer-fine { color: var(--text-3); font-size: 0.85rem; margin: 0.25rem 0 0; } +.sizer-why { color: var(--text-2); font-size: 0.9rem; margin: 0.4rem 0 0; } +.sizer-sub { font-size: 1rem; margin: 1.25rem 0 0.5rem; } +.sizer-cpu { margin: 0; padding-left: 1.1rem; color: var(--text-2); line-height: 1.6; } +.sizer-cpu strong { color: var(--text-1); font-weight: 600; } + +/* fold-out categories: native
, dressed like the FAQ's items (gyik.html) */ +.sizer-cat { background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--radius); margin-bottom: 12px; overflow: hidden; transition: border-color 0.3s ease; } +.sizer-cat:hover { border-color: var(--blue); } +.sizer-cat-head { list-style: none; display: flex; align-items: center; gap: 0.75rem; padding: 16px 20px; cursor: pointer; color: var(--text-1); font-weight: 600; transition: background 0.2s ease; } +.sizer-cat-head::-webkit-details-marker { display: none; } +.sizer-cat-head:hover { background: var(--blue-dim); } +.sizer-cat-head:focus-visible { outline: 2px solid var(--blue-bright); outline-offset: -2px; } +.sizer-cat-name { flex: 1 1 auto; min-width: 0; } +.sizer-cat-count { flex: 0 0 auto; font-family: var(--font-data); font-size: 0.8rem; font-weight: 500; color: var(--blue-bright); } +.sizer-chevron { flex: 0 0 auto; color: var(--blue-bright); transition: transform 0.3s ease; } +.sizer-cat[open] .sizer-chevron { transform: rotate(180deg); } +.sizer-cat-body { padding: 0 20px 14px; } .sizer-app { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.15rem 0.6rem; padding: 0.35rem 0; cursor: pointer; } .sizer-app input { accent-color: var(--blue); width: 1.05rem; height: 1.05rem; margin: 0; flex: 0 0 auto; align-self: center; } .sizer-app input:focus-visible { outline: 2px solid var(--blue-bright); outline-offset: 2px; } .sizer-app input:disabled + .sizer-app-name { color: var(--text-2); } .sizer-app-name { color: var(--text-1); } .sizer-app-tag { color: var(--text-3); font-size: 0.85rem; } -.sizer-result h2 { font-size: 1.15rem; margin: 0 0 0.75rem; } -.sizer-totals { color: var(--text-2); margin: 0; line-height: 1.7; } -.sizer-num { font-family: var(--font-data); color: var(--text-1); font-weight: 600; } -.sizer-fine { color: var(--text-3); font-size: 0.85rem; margin: 0.25rem 0 0; } -.sizer-boxes { list-style: none; margin: 1rem 0; padding: 0; display: grid; gap: 0.75rem; } -.sizer-box { border: 1px solid var(--line); border-left-width: 3px; border-radius: var(--radius); padding: 0.75rem 1rem; background: var(--bg-1); } -.sizer-box-head { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; } -.sizer-box-size { font-family: var(--font-data); color: var(--text-1); } -.sizer-verdict { color: var(--text-1); } -.sizer-well { border-left-color: var(--blue); } -.sizer-well .ico { color: var(--blue-bright); } -.sizer-tight { border-left-color: var(--warn); background: var(--warn-dim); } -.sizer-tight .ico, .sizer-tight .sizer-verdict { color: var(--warn); } -.sizer-no { border-left-color: var(--crit); background: var(--crit-dim); } -.sizer-no .ico, .sizer-no .sizer-verdict { color: var(--crit); } -.sizer-why { color: var(--text-2); font-size: 0.9rem; margin: 0.4rem 0 0; } -.sizer-sub { font-size: 1rem; margin: 1.25rem 0 0.5rem; } -.sizer-cpu { margin: 0; padding-left: 1.1rem; color: var(--text-2); line-height: 1.6; } -.sizer-cpu strong { color: var(--text-1); font-weight: 600; } + .sizer-advice { margin: 0; padding-left: 1.1rem; color: var(--text-2); line-height: 1.7; } .sizer-how { margin-top: 1.5rem; color: var(--text-2); } .sizer-how summary { cursor: pointer; color: var(--text-1); } .sizer-how summary:focus-visible { outline: 2px solid var(--blue-bright); outline-offset: 2px; } .sizer-how p { margin: 0.5rem 0 0; line-height: 1.65; } .sizer-noscript { color: var(--warn); } -@media (max-width: 900px) { - .sizer-layout { grid-template-columns: minmax(0, 1fr); gap: 1.5rem; } + +/* a phone: the shell's top bar is sticky at the top (≈ 53 px, shell.css), so the memory bar sits under it */ +@media (max-width: 768px) { + .sizer-sticky { top: 53px; } + .sizer-mark { font-size: 0.66rem; } } diff --git a/website/assets/sizing.js b/website/assets/sizing.js index 63bffc20..e12bcfbc 100644 --- a/website/assets/sizing.js +++ b/website/assets/sizing.js @@ -1,15 +1,17 @@ /* sizing.js — the machine estimator (/gepigeny, /en/sizing). * - * THE RULE lives in estimate() and guestMib() below, and nowhere else on the site. Every number it uses - * comes from /assets/sizing-data.json (built by scripts/build_sizing_web.py from the 2026-10-13 N100 + * THE RULE lives in guestMib(), minBoxGb() and estimate() below, and nowhere else on the site. Every number it + * uses comes from /assets/sizing-data.json (built by scripts/build_sizing_web.py from the 2026-10-13 N100 * measurement); this file holds none of its own — scripts/test_build_sizing_web.py checks that. * * typical = platform + sum of the selected apps' typical figures * peak = platform + sum of their peaks (all at peak at once: pessimistic on purpose) - * per box: guest memory by the installer's rule, then + * per box size: guest memory by the installer's rule (guestMib), then * well peak * headroom <= guest * tight peak <= guest < peak * headroom * no guest < peak + * the memory bar: minBoxGb() inverts guestMib() by SEARCHING it — the smallest box memory whose guest + * holds peak * headroom (solid fill) and peak (thin marker). It never restates the installer's rule. * * The page text is not here: each page carries its own strings (#sizer-strings), so this file is the * same for both languages. @@ -27,6 +29,18 @@ return mem; } + // The smallest box memory (GB, not rounded to a product size) whose guest holds needMib. guestMib() never + // decreases as the box grows, so a bisection on it is exact to 1 MiB; it uses the forward rule only. + function minBoxGb(data, needMib) { + var lo = 0, hi = 1; + while (guestMib(data, hi) < needMib) { hi = hi * 2; } + while (hi - lo > 1 / 1024) { + var mid = (lo + hi) / 2; + if (guestMib(data, mid) >= needMib) { hi = mid; } else { lo = mid; } + } + return hi; + } + function estimate(data, slugs) { var bySlug = {}; data.apps.forEach(function (a) { bySlug[a.slug] = a; }); @@ -44,11 +58,20 @@ var verdict = peak * data.headroom <= guest ? 'well' : (peak <= guest ? 'tight' : 'no'); return { gb: gb, guest_mb: guest, verdict: verdict }; }); + var recommend = null, tightBox = null; + boxes.forEach(function (b) { + if (recommend === null && b.verdict === 'well') { recommend = b.gb; } + if (tightBox === null && b.verdict === 'tight') { tightBox = b.gb; } + }); var heavy = busy.filter(function (a) { return a.cpu_class === 'heavy-in-bursts'; }); - return { typical_mb: typical, peak_mb: peak, boxes: boxes, busy: busy, heavy: heavy, unmeasured: unmeasured }; + return { + typical_mb: typical, peak_mb: peak, boxes: boxes, busy: busy, heavy: heavy, unmeasured: unmeasured, + well_gb: minBoxGb(data, peak * data.headroom), tight_gb: minBoxGb(data, peak), + recommend_gb: recommend, tight_box_gb: tightBox + }; } - root.FelhomSizing = { estimate: estimate, guestMib: guestMib }; + root.FelhomSizing = { estimate: estimate, guestMib: guestMib, minBoxGb: minBoxGb }; /* ---------------------------------------------------------------- the page (drawing only) */ if (typeof document === 'undefined') { return; } @@ -60,10 +83,10 @@ return e; } - function icon(name) { + function icon(name, cls) { var ns = 'http://www.w3.org/2000/svg'; var s = document.createElementNS(ns, 'svg'); - s.setAttribute('class', 'ico'); + s.setAttribute('class', cls || 'ico'); s.setAttribute('aria-hidden', 'true'); var u = document.createElementNS(ns, 'use'); u.setAttribute('href', '/assets/icons.svg?v=1#' + name); @@ -77,17 +100,36 @@ if (!host || !strEl) { return; } var T = JSON.parse(strEl.textContent); var lang = document.documentElement.lang === 'en' ? 'en' : 'hu'; - var gbText = function (mb) { - var v = (mb / 1024).toFixed(1); - return (lang === 'hu' ? v.replace('.', ',') : v) + ' GB'; - }; + var dec = function (v) { var s = v.toFixed(1); return lang === 'hu' ? s.replace('.', ',') : s; }; + var gbText = function (mb) { return dec(mb / 1024) + ' GB'; }; var appsBox = document.getElementById('sizer-apps'); + var meter = document.getElementById('sizer-meter'); + var line = document.getElementById('sizer-line'); var result = document.getElementById('sizer-result'); fetch(host.getAttribute('data-src')).then(function (r) { return r.json(); }).then(function (data) { + var max = data.bar_max_gb; + meter.setAttribute('aria-valuemax', String(max)); + var groups = []; + + // the bar's marks: positions from the data, the last one reads "64+" + var scale = document.getElementById('sizer-scale'); + data.bar_marks_gb.forEach(function (gb, i) { + var m = el('span', 'sizer-mark', gb + (i === data.bar_marks_gb.length - 1 ? '+' : '') + ' GB'); + m.style.left = (gb / max * 100) + '%'; + scale.appendChild(m); + }); + + // one fold-out per category, all closed at first data.categories.forEach(function (c) { - var fs = el('fieldset', 'sizer-group'); - fs.appendChild(el('legend', '', lang === 'en' ? c.name_en : c.name_hu)); + var d = el('details', 'sizer-cat'); + var sum = el('summary', 'sizer-cat-head'); + sum.appendChild(el('span', 'sizer-cat-name', lang === 'en' ? c.name_en : c.name_hu)); + var count = el('span', 'sizer-cat-count'); + sum.appendChild(count); + sum.appendChild(icon('i-chevron-down', 'ico sizer-chevron')); + d.appendChild(sum); + var body = el('div', 'sizer-cat-body'); data.apps.filter(function (a) { return a.category === c.id; }).forEach(function (a) { var lab = el('label', 'sizer-app'); var cb = el('input'); @@ -95,13 +137,14 @@ cb.value = a.slug; if (a.builtin) { cb.checked = true; cb.disabled = true; } lab.appendChild(cb); - var name = el('span', 'sizer-app-name', lang === 'en' ? a.name_en : a.name_hu); - lab.appendChild(name); + lab.appendChild(el('span', 'sizer-app-name', lang === 'en' ? a.name_en : a.name_hu)); var tag = a.builtin ? T.builtin : (lang === 'en' ? a.tagline_en : a.tagline_hu); if (tag) { lab.appendChild(el('span', 'sizer-app-tag', tag)); } - fs.appendChild(lab); + body.appendChild(lab); }); - appsBox.appendChild(fs); + d.appendChild(body); + appsBox.appendChild(d); + groups.push({ details: d, count: count }); }); function selected() { @@ -111,6 +154,34 @@ function draw() { var r = estimate(data, selected()); + + groups.forEach(function (g) { + var n = g.details.querySelectorAll('input[type=checkbox]:checked:not(:disabled)').length; + g.count.textContent = n ? T.selected.replace('{n}', n) : ''; + }); + + // the bar: solid fill = fits well, thin marker = fits, but tight + var wellPct = Math.min(r.well_gb / max, 1) * 100; + var tightPct = Math.min(r.tight_gb / max, 1) * 100; + document.getElementById('sizer-fill').style.width = wellPct + '%'; + document.getElementById('sizer-tight').style.left = tightPct + '%'; + var over = r.well_gb > max; + var needText = over ? T.over_max.replace('{gb}', max) : dec(r.well_gb) + ' GB'; + meter.setAttribute('aria-valuenow', String(Math.min(r.well_gb, max).toFixed(1))); + meter.setAttribute('aria-valuetext', T.meter_text.replace('{x}', needText)); + + // one line: which box to buy, and where it is tight + var biggest = data.boxes_gb[data.boxes_gb.length - 1]; + if (r.recommend_gb !== null) { + line.textContent = T.recommend.replace('{gb}', r.recommend_gb) + + (r.tight_box_gb !== null ? ' ' + T.tight_on.replace('{gb}', r.tight_box_gb) : ''); + } else if (r.tight_box_gb !== null) { + line.textContent = T.only_tight.replace('{gb}', r.tight_box_gb); + } else { + line.textContent = T.none_fits.replace('{gb}', biggest); + } + + // under the bar: totals, why tight is tight, the CPU lines result.textContent = ''; var totals = el('p', 'sizer-totals'); totals.appendChild(el('span', '', T.typical + ': ')); @@ -119,20 +190,7 @@ totals.appendChild(el('strong', 'sizer-num', gbText(r.peak_mb))); result.appendChild(totals); result.appendChild(el('p', 'sizer-fine', T.platform_note)); - - var ul = el('ul', 'sizer-boxes'); - r.boxes.forEach(function (b) { - var li = el('li', 'sizer-box sizer-' + b.verdict); - var head = el('div', 'sizer-box-head'); - head.appendChild(icon(b.verdict === 'well' ? 'i-check' : (b.verdict === 'tight' ? 'i-triangle-alert' : 'i-x'))); - head.appendChild(el('strong', 'sizer-box-size', b.gb + ' GB')); - head.appendChild(el('span', 'sizer-verdict', T.verdict[b.verdict])); - li.appendChild(head); - li.appendChild(el('p', 'sizer-fine', T.guest.replace('{x}', gbText(b.guest_mb)))); - if (b.verdict === 'tight') { li.appendChild(el('p', 'sizer-why', T.tight_why)); } - ul.appendChild(li); - }); - result.appendChild(ul); + if (r.tight_box_gb !== null) { result.appendChild(el('p', 'sizer-why', T.tight_why)); } if (r.busy.length) { result.appendChild(el('h3', 'sizer-sub', T.cpu_head)); @@ -161,8 +219,13 @@ Array.prototype.forEach.call(document.querySelectorAll('[data-preset]'), function (btn) { btn.addEventListener('click', function () { var want = (btn.getAttribute('data-preset') || '').split(',').filter(Boolean); - Array.prototype.forEach.call(appsBox.querySelectorAll('input[type=checkbox]:not(:disabled)'), function (c) { - c.checked = want.indexOf(c.value) !== -1; + groups.forEach(function (g) { + var any = false; + Array.prototype.forEach.call(g.details.querySelectorAll('input[type=checkbox]:not(:disabled)'), function (c) { + c.checked = want.indexOf(c.value) !== -1; + if (c.checked) { any = true; } + }); + if (any) { g.details.open = true; } }); draw(); }); @@ -170,7 +233,7 @@ host.hidden = false; draw(); }).catch(function () { - result.textContent = T.load_error; + line.textContent = T.load_error; host.hidden = false; }); } diff --git a/website/en/sizing.html b/website/en/sizing.html index 3796a010..1607bb8f 100644 --- a/website/en/sizing.html +++ b/website/en/sizing.html @@ -18,8 +18,8 @@ - - + +
@@ -70,25 +70,26 @@ -

Tick what you would run on the box, and the page shows how it fits an 8, 16 or 32 GB machine. This is an estimate from my own measurements on an N100 mini PC (13 October 2026), not a promise.

+

Tick what you would run on the box, and the page shows how much machine memory it needs. This is an estimate from my own measurements on an N100 mini PC (13 October 2026), not a promise.

-