Facebook Page: profile pictures, three covers, preview page and texts (marketing/facebook); R-916 opened
gates / gates (push) Successful in 4m24s

build.py (Pillow, site tokens + Plus Jakarta Sans) with in-build checks: profile circle (today's shape 6823 pixels
outside, new 0), cover safe area and profile-circle clearance, sizes read back. COPY.md: intro 99/100, long
description, Messenger welcome, three first posts, each claim traced to the website. R-914 gets the listing-based
removal proof and the picture-API tasks; R-915 the footer-link note; R-916 the logo's vector master.
This commit is contained in:
2026-10-08 19:19:50 +02:00
parent e9e6300cfc
commit f0dc6a9d4e
14 changed files with 670 additions and 8 deletions
+1 -1
View File
@@ -75,7 +75,7 @@ box**.
explicit paths only, `git pull --rebase` before every push.
- **`REPORT.md` is overwritten, so two sessions in this repo clobber each other.** The second session
writes **`REPORT-<topic>.md`** and never touches the shared `REPORT.md`.
- `CHANGELOG.md` here is **per-area**: `hub/`, `scripts/`, `website/`.
- `CHANGELOG.md` here is **per-area**: `hub/`, `scripts/`, `website/`, `marketing/`.
<!-- The sweep incident: v0.47.0, commit 146d165 — a red-proof-mutated guard got swept to main. -->
+57
View File
@@ -0,0 +1,57 @@
# REPORT — Facebook Page pictures and texts (2026-10-08)
Own file (parallel sessions share this clone). Baseline `felhom.eu` @ `e9e6300cfc`.
## For the operator — your click list, in order
1. Open `marketing/facebook/out/preview.html` from Gitea (Download, then open the file). Pick cover A, B or C.
2. Page → profile picture → upload `marketing/facebook/out/profile-dark.png` (recommended).
3. Page → cover photo → upload the cover you picked.
4. Page settings → username → set it, for example `felhom`.
5. Page → action button → „Weboldal" (Website) → `https://felhom.eu`.
6. Page → Intro / Bemutatkozás → paste `COPY.md` §1. About → details → paste §2.
7. Meta Business Suite → Inbox → automations → welcome message → paste §3 and turn it on.
8. The three posts (§4) wait until the app is Live (R-915). If you post them now, only people with a role on the app see them.
## Results
| Item | Result |
|---|---|
| Logo master | `website/assets/logo.png` (645 x 408). `logo.svg` refused: its lettering is live text in fonts DooPlex lacks („M+ 2c", „Vremena Grotesk") → R-916 |
| Colours | `site.css` `:root`: bg `#0A1220`/`#0F1B2E`/`#16263F`, line `#22344F`, text `#EDF2F9`/`#94A6BF`, blue `#0083D8`/`#2EA8F5`. The site is dark-only (one token set) |
| Font | Plus Jakarta Sans from `website/assets/fonts/*.woff2` (ExtraBold, SemiBold) |
| Renderer | Pillow 11.1.0. librsvg was tried and dropped: pango fell back to DejaVu for woff2, and the SVG logo draws the wrong letters |
| Circle check | today's shape (logo.png on a 645 square): **6823** pixels outside radius 0.42 x width. New pictures: **0** and **0** |
| Safe area | covers B and C **failed first** (51 content pixels in the phone profile circle's corner; the frame past the phone crop); moved; all three now 0 and inside (306, 40, 1334, 584) |
| Sizes read from the files | profile-dark 932 x 932, profile-white 932 x 932, cover-a/b/c 1640 x 624 each |
| Profile background | dark recommended: the lettering is white with a thin navy edge and fades on white at 40 px (by eye, 176 + 40 px mock) |
| Copy | intro 99 of 100 characters; no price; „kérjük" absent (ASCII fragment `kerjuk` + accented form; controls: `szerver` found, `xyzzy` not); links `/alkalmazasok`, `/kapcsolat`, `/` answer 200 |
**Sizes (READ, second-hand — Meta's help page needs a browser; Claude in Chrome was not available: no
`claude-in-chrome` tools in this session (searched by name and keyword), and the workspace notes say it does not run on DooPlex):**
cover 820 x 312 computer, 640 x 360 phone; profile ~176 px; profile circle at 16/176 px (computer), 24/196 px
(phone) — postfa.st/sizes/facebook/cover, linearity.io/templates/facebook-size-guide, fasturtle.com.
**First-hand (Meta developer docs):** Page `about` 100 characters; `POST /{page}/picture` needs the `MANAGE` task;
the cover needs `EDIT_PROFILE` + `business_management` (developers.facebook.com/docs/graph-api/reference/page/, …/page/picture).
**Gitea paths:** `https://gitea.dooplex.hu/admin/felhom.eu/src/branch/main/marketing/facebook/out/` — `preview.html`,
`profile-dark.png`, `profile-white.png`, `cover-a.png`, `cover-b.png`, `cover-c.png`.
**Register:** 138 → 139. Opened R-916 (logo vector master, operator). Edited R-914 (scheduled-post listing as the removal
proof; the picture API's tasks), R-915 (footer link after Live). Closed 0. The brief expected 0 opened: R-916 is a gap I
cannot fix here (it needs the fonts), so by the size rule it is a row.
**Teardown:** no Graph API call, no key read. Host: nothing provisioned. Hub: nothing. Facebook: nothing.
## Fixed without a row
- Covers B and C, caught by the build's own safe-area check, moved before the first commit.
- `CLAUDE.md` line 78: „per-area: `hub/`, `scripts/`, `website/`" → adds `marketing/` (a new area with its own CHANGELOG).
## Observations
- The Planner screenshot (operator, 2026-10-08) shows nothing on 15 October, the test posts' date — a different-channel
look at the spike's removals — NOT-A-FINDING: recorded in R-914, which still asks for the listing proof.
- The dashboard screenshots used on the website show the demo box's domain; cover C covers it in its copy —
NOT-A-FINDING: already public on the website, covered anyway.
- `logo.svg` lettering — FILED: R-916.
+7 -4
View File
@@ -3,15 +3,18 @@
**Ready for the first real tester (Tester-2): yes. Tester 2 (a laptop) is off; nothing was sent to it.**
**Updated 2026-10-08 (evening): hub 0.143.1; demo-hp, demo-felhom and Tester 1 run agent 0.153.0 and controller
0.303.0 (nothing delivered today — tonight is the second kernel night). The open-items list is at 138. Reports:
0.303.0 (nothing delivered today — tonight is the second kernel night). The open-items list is at 139. Reports:
`REPORT-day-2026-10-08.md` (morning), `REPORT-day2-2026-10-08.md` (afternoon), `REPORT-day3-2026-10-08.md` (evening).**
## Facebook Page (2026-10-08): Claude can post — the public cannot see it yet
## Facebook Page (2026-10-08): Claude can post; the pictures and texts are ready for you
- The robot key works. It never runs out. After your Page click, it reaches the Felhom.eu Page.
- Test: one scheduled text post and one scheduled photo. Accents came back exact. Both deleted, and proven gone.
- The robot key works and reaches the Page. A test post and a test photo were made, checked and deleted.
- **New pictures and texts (needs you, about 10 minutes):** open the preview page, pick one of three covers, upload
the profile picture and the cover by hand, paste the texts. The list is in the pictures report.
- **Later, before real posts:** switch the Meta app to „Live". It needs a Terms of Service web address (the
legal pages item). If you do nothing: posts are seen only by people with a role on the app.
- **When you have time:** the logo needs a clean vector copy, made on the machine that has its fonts. If you do
nothing: the pictures stay as they are; large prints of the logo will be soft.
## Evening (2026-10-08): your ten answers (D1–D10) built — they ship tomorrow
+4 -3
View File
@@ -263,7 +263,7 @@ stopping line that lies.
| **R-814** | Hub & operator | P4 | `PBS-storage-1` (u629193, box 611421) still `status=active`, 19.9 MB | **VERIFY** (2026-10-03 triage: a July watch row with no id; given R-814. WAITING-ON-OPERATOR — no record found that the box was deleted.) — WAITING-ON-OPERATOR | operator console | Delete the box | operator |
| **R-844** | Hub & operator | P4 | **The household's OS-update line exists only on the hub's customer timeline.** 2026-10-04: the box itself has no event surface for agent results (the controller UI shows no timeline), so `os_update_applied` is a hub customer event (info: recorded, never mailed). Its stored text is the hub's English sentence; the hu/en bundle text (`mail.event.os_update_applied`) is used only if it is ever mailed. Fix direction: a controller-side line (the controller already polls the agent's local API) when the box gets a household timeline. `audits/os-guest-lane-2026-10-04/partG/hub-customer-timeline-demo-hp.txt` | **READY — owner: CC** **2026-10-05 (burn-down night): NEEDS A DESIGN** — a household timeline on the box does not exist yet. | — | — | CC |
## Business & legal — 8 rows (P2 4, P4 4)
## Business & legal — 9 rows (P2 4, P4 5)
| ID | Category | Sev | What | State | Blocked on | Next action | Owner |
|---|---|---|---|---|---|---|---|
@@ -274,8 +274,9 @@ stopping line that lies.
| **R-901** | Business & legal | P2 | **Two kinds of a household's data outlive the deletion of the customer, and no document says when they go.** FOUND 2026-10-08 (R-813 drafting), read in source: the hub keeps `events` and `notification_log` on purpose after a customer is deleted (`hub/internal/store/customer_delete.go:24-26`, „the audit trail outlives every lifecycle tier") and nothing prunes `notification_log`; DooPlex's copy of ep0 (`ep0-copy`) pulls with `remove-vanished false` and prunes only to keep-weekly 8 (`runbooks/ep0-datastore-copy.md`), so a deleted customer's last 8 weekly encrypted whole-guest copies stay on DooPlex with no end date. A privacy notice cannot promise deletion until this is decided. **-- 2026-10-08 14:16 operator ruling D9 (`09` §3 decision 193):** yes — CC installs the DooPlex job, dry run first, then daily, after the 2026-10-09 releases are read back. | **READY — ruled 2026-10-08 09:04 (`09` §3 decision 181): audit rows (`events`, `notification_log`) of a deleted customer kept 1 year, then deleted; the customer's `ep0-copy` namespace removed within 30 days; both go into the privacy-notice draft.** Close only when both are live. | R-813 | Build the hub's daily deletion (ships with the next hub release) and the DooPlex `ep0-copy` removal job (written; run needs the operator's word); write both times into the privacy-notice draft | CC |
| **R-89** | Business & legal | P4 | Retention as a per-customer **commercial** policy on the hub | READY (increment 2) | — | Policy object + reconciler → ep0 prune job; keep box tokens write-only | CC |
| **R-794** | Business & legal | P4 | **[P3-LOW] redis 7.4 (RSALv2 / SSPL, not OSI) runs as a private cache in seven apps: dawarich, docmost, immich, nextcloud, outline, paperless-ngx, romm.** READ 2026-10-02 (`audits/licences-2026-10-02/TABLE.md`). Read as permitted (a private cache only its app uses is not Redis offered as a service — inferred). Valkey (BSD-3) or redis 8 (AGPL option) removes the question. **Needs:** a ladder step per app to valkey or redis 8, through the harness — no hurry. | **READY — rank P3-LOW; owner: CC** **Re-ranked 2026-10-03: P3→P4: the row itself says no hurry; usage read as permitted.** | — | — | CC |
| **R-914** | Business & legal | P4 | **Write the Felhom Facebook Page skill from the spike's findings.** Spike 2026-10-08 (`audits/SPIKE-facebook-page-api-2026-10-08.md`): key valid, never expires; the Page (`1360018983863273`) is reached with CREATE_CONTENT/MODERATE/ANALYZE; a scheduled text post and a scheduled photo were created, read back byte-equal and deleted (removal proven). Probe `scripts/facebook/fb_probe.py`. Gap to close in the skill: a scheduled photo's publish state was not read (no `post_id` returned). | **READY — owner: CC** | — | Write the skill (drafts scheduled for operator review by default); read a scheduled photo back through the Page's scheduled-post listing | CC |
| **R-915** | Business & legal | P4 | **The Meta app `felhom.eu` is in development mode, so posts it makes are seen only by people with a role on the app.** READ 2026-10-08 (Meta docs, cited in the spike); not measured. MEASURED: development mode does not refuse posting (both test writes HTTP 200). Live needs display name, contact e-mail, a Terms of Service URL, an app icon, a category and the app purpose (privacy-policy and data-deletion URLs listed beside them). The robot's Page assignment, missing at first, was done by the operator the same day. | **WAITING-ON-OPERATOR** | R-813 (the Terms of Service URL) | Before real public posts: switch the app to Live in the Meta developer page. If nothing is done: posts stay invisible to the public | operator |
| **R-914** | Business & legal | P4 | **Write the Felhom Facebook Page skill from the spike's findings.** Spike 2026-10-08 (`audits/SPIKE-facebook-page-api-2026-10-08.md`): key valid, never expires; the Page (`1360018983863273`) is reached with CREATE_CONTENT/MODERATE/ANALYZE; a scheduled text post and a scheduled photo were created, read back byte-equal and deleted (removal proven). Probe `scripts/facebook/fb_probe.py`. Gap to close in the skill: a scheduled photo's publish state was not read (no `post_id` returned). **2026-10-08 (Page pictures task) — two more for the skill:** (1) **the removal proof was not a proof**: the text post's after-DELETE answer was (#10) „does not exist, cannot be loaded due to missing permission…" — that message also means a permission gap. The skill proves removal by listing the Page's scheduled posts before and after the delete (present, then absent); the operator's Planner view on 2026-10-08 showed nothing on 15 October (a different channel, by eye). (2) **pictures by API** (READ, developers.facebook.com/docs/graph-api/reference/page/picture and …/reference/page/): `POST /{page}/picture` needs the `MANAGE` task, which the robot does not have (measured task list); the cover is `POST /{page}` `cover=<photo id>`, „only by the Page Admin or Page Editor with `EDIT_PROFILE`" + `business_management`. Neither names `pages_manage_metadata`. Today the pictures are uploaded by hand (`marketing/facebook/README.md`). | **READY — owner: CC** | — | Write the skill (drafts scheduled for operator review by default); read a scheduled photo back through the Page's scheduled-post listing | CC |
| **R-915** | Business & legal | P4 | **The Meta app `felhom.eu` is in development mode, so posts it makes are seen only by people with a role on the app.** READ 2026-10-08 (Meta docs, cited in the spike); not measured. MEASURED: development mode does not refuse posting (both test writes HTTP 200). Live needs display name, contact e-mail, a Terms of Service URL, an app icon, a category and the app purpose (privacy-policy and data-deletion URLs listed beside them). The robot's Page assignment, missing at first, was done by the operator the same day. | **WAITING-ON-OPERATOR** | R-813 (the Terms of Service URL) | Before real public posts: switch the app to Live in the Meta developer page. If nothing is done: posts stay invisible to the public. After Live: CC adds the Page link to the website footer (not before — a link to a Page nobody can read is worse than none) | operator |
| **R-916** | Business & legal | P4 | **The logo has no usable vector master: `website/assets/logo.svg` sets „felhom.eu" as live text in the fonts „M+ 2c" and „Vremena Grotesk", which DooPlex does not have, so every renderer here draws other letters.** SEEN 2026-10-08 (Facebook pictures task): librsvg drew the lettering in DejaVu; `fc-match` resolves the family to DejaVu Sans. The PNG (645 x 408) is the only faithful copy, which caps every picture made from it at about that size (`marketing/facebook/README.md`). | **WAITING-ON-OPERATOR** | the machine with the fonts | In Inkscape on that machine: select the lettering → Path → Object to Path → save as `website/assets/logo-master.svg`; then `marketing/facebook/build.py` can use it. If nothing is done: the PNG stays the master; larger prints will be soft | operator |
## Process & tooling — 23 rows (P3 3, P4 20)
+13
View File
@@ -0,0 +1,13 @@
# marketing — CHANGELOG
## facebook — the Page's pictures and texts (2026-10-08)
- `marketing/facebook/build.py` (Pillow only) makes `out/profile-dark.png` + `out/profile-white.png` (932 x 932, the
logo PNG placed unscaled), three covers `out/cover-{a,b,c}.png` (1640 x 624) and `out/preview.html` (self-contained).
Checks in the build: profile circle (today's shape 6823 pixels outside radius 0.42 x width → new pictures 0), cover
safe area + profile-circle clearance (content layer only), sizes read back from the files. The site's tokens and font.
- `COPY.md`: intro (99 of 100 characters), long description, Messenger welcome, three first posts — each claim cites its
website page; no price.
- Fixed in the session: covers B and C first failed the safe-area check (text in the phone profile circle's corner, the
frame past the phone crop) and were moved; `logo.svg` renders with the wrong lettering here, so the PNG is the master
(R-916).
+85
View File
@@ -0,0 +1,85 @@
# Felhom.eu Facebook Page — texts to paste
Every claim cites the website page it comes from (`<!-- forrás: … -->`, not pasted). No price, no promise the
site does not make. Prices live only on the non-public services page and stay off Facebook.
## 1. Rövid leírás (Bemutatkozás / Intro — legfeljebb 100 karakter)
<!-- forrás: index.html hero („Saját felhőd, saját szabályaid", „Te irányítasz, mi segítünk") -->
<!-- limit: Graph API Page `about` = 100 karakter (developers.facebook.com/docs/graph-api/reference/page/) -->
```
Saját felhőd, saját szabályaid. Otthoni szerver telepítés, üzemeltetés. Te irányítasz, mi segítünk.
```
## 2. Hosszabb leírás (Névjegy / Részletek)
<!-- forrás: index.html — „Mit tud a doboz ma?", „Miért válaszd a saját szervered?", „Zárt teszt", kapcsolat -->
```
A Felhom egy otthoni szerver, ami nálad van: a fotóid, a dokumentumaid és a médiatárad a saját gépeden maradnak, nem egy nagy techcég szerverén.
Mit tud ma a doboz:
– A telefonod fotói automatikusan feltöltődnek a saját gépedre.
– A beszkennelt papírjaidat a rendszer felismeri és kereshetővé teszi.
– A doboz mappái megjelennek a Windows Intézőben és a Mac Finderben.
– Minden éjjel mentés készül: a dobozon, egy második meghajtón (ha van), és titkosítva egy távoli tárhelyen.
– A távoli mentés kulcsa egy helyreállítási kód, amit csak te ismersz.
– 56 alkalmazás telepíthető pár kattintással, parancssor nélkül.
– Ha elmegy a net, otthon továbbra is eléred a fotóidat és a fájljaidat.
Mi figyeljük a doboz állapotát, és frissítjük a rendszert. Te csak használod.
Most néhány magyar háztartással zárt tesztet indítunk. Ha érdekel, írj: https://felhom.eu/kapcsolat
```
## 3. Messenger üdvözlő üzenet
<!-- forrás: index.html „Zárt teszt" („nem kötelez semmire, először csak beszélgetünk") -->
```
Szia! Köszi, hogy írtál a Felhomnak. Írd meg, mi érdekel – a zárt tesztről vagy az otthoni szerverről –, és válaszolunk.
```
## 4. Az első három bejegyzés
### 4.1 Mi a Felhom? — kép: `out/cover-b.png`
<!-- forrás: index.html hero; „Mit tud a doboz ma?" (fotók, papírok, mappák, mentés); „Zárt teszt" -->
```
Saját felhőd, saját szabályaid.
A Felhom egy otthoni szerver, amit mi telepítünk és üzemeltetünk – te pedig használod. A telefonod fotói maguktól feltöltődnek rá, a beszkennelt papírjaid kereshetők lesznek, a mappái pedig ott vannak a Windows Intézőben vagy a Mac Finderben.
Most néhány magyar háztartással zárt tesztet kezdünk. Ha kipróbálnád, írj nekünk: https://felhom.eu/kapcsolat
```
### 4.2 Az adataid otthon maradnak — kép: `out/cover-c.png`
<!-- forrás: index.html „Teljes adatvédelem", „Helyreállítási kód, amit csak te ismersz", „Háromszintű automatikus mentés", „Internetkimaradáskor is működik" -->
```
Hol vannak most a családi fotóid?
A Felhommal a te otthonodban. Nem a Google vagy a Microsoft szerverein, hanem a saját gépeden.
Minden éjjel mentés készül: a dobozon, egy második meghajtón (ha van), és egy titkosított másolat egy távoli tárhelyre. A titkosítás kulcsa egy helyreállítási kód, ami egyszer jelenik meg – a te képernyődön. Mi nem ismerjük, és nem tudjuk visszaállítani, ezért tedd el jól.
És ha elmegy a net? Otthon továbbra is eléred a fotóidat és a fájljaidat.
https://felhom.eu
```
### 4.3 Milyen alkalmazásokat futtathatsz? — kép: `out/cover-a.png`
<!-- forrás: index.html „56 telepíthető alkalmazás" és a kategóriák listája; alkalmazasok.html (a galéria) -->
```
56 alkalmazás, pár kattintással – parancssor nélkül.
Fotók és média: Immich, Jellyfin, Plex, Audiobookshelf. Fájlok: Nextcloud, FileBrowser. Dokumentumok: Paperless-ngx, BookStack. Jelszavak: Vaultwarden. Otthon: Mealie, Homebox, Actual Budget.
Szinte mind nyílt forráskódú, és mind a te gépeden fut. Nézd meg mindet, képekkel:
https://felhom.eu/alkalmazasok
```
+51
View File
@@ -0,0 +1,51 @@
# marketing/facebook — the Felhom.eu Facebook Page's pictures and texts
Files the operator uploads and pastes by hand. Nothing here talks to Facebook (the robot posting path is R-914).
## Rebuild
```
python3 marketing/facebook/build.py
```
One command makes every PNG and `out/preview.html`, and runs the checks: the profile circle (no logo pixel
outside 0.42 x width; shown failing first on today's shape), the cover safe area (content inside what computer
AND phone show, clear of the profile circle), and the sizes read back from the files. Exit 0 = all passed.
Needs Python 3 + Pillow only. Brand source: `website/assets/logo.png` (the logo master — see below),
`website/assets/site.css` tokens, `website/assets/fonts/` (Plus Jakarta Sans).
## What goes where
| File | Where on the Page |
|---|---|
| `out/profile-dark.png` (recommended) or `out/profile-white.png` | Profile picture |
| `out/cover-a.png` / `cover-b.png` / `cover-c.png` — pick one | Cover photo |
| `COPY.md` §1 | Intro / Bemutatkozás (≤ 100 characters) |
| `COPY.md` §2 | About → details (longer description) |
| `COPY.md` §3 | Messenger welcome message |
| `COPY.md` §4 | The first three posts, each with its picture |
Open `out/preview.html` (download it from Gitea, open locally — it is self-contained) to see each picture the way
Facebook shows it.
## Sizes used (READ, second-hand)
Meta's own help page (facebook.com/help/125379114252045) needs a browser and could not be read from DooPlex.
Figures from size guides that quote it: cover shown **820 x 312** on computers and **640 x 360** on phones
(built at 2 x: 1640 x 624); profile picture shown about 176 px; profile circle over the cover at 16 px / 176 px
(computer) and 24 px / 196 px (phone). Sources: postfa.st/sizes/facebook/cover,
linearity.io/templates/facebook-size-guide, fasturtle.com (faq-items/what-size-photos-do-i-use-on-facebook).
Page `about` = 100 characters: developers.facebook.com/docs/graph-api/reference/page/ (Meta, first-hand).
## The logo master
`website/assets/logo.svg` is **not** usable as a master: its „felhom.eu" lettering is live text in the fonts
„M+ 2c" and „Vremena Grotesk", which DooPlex does not have, so any renderer here draws other letters. The PNG
(645 x 408) is placed unscaled, so the profile picture is 932 x 932 — sharp, not enlarged. A vector master with the
lettering converted to paths (Inkscape: Path → Object to Path, on the machine that has the fonts) is R-916.
## Cover C
Uses `website/assets/dashboard-backups-hu.webp` (demo box, already public on the website). The demo box's domain
chip next to the title is covered with the page colour in the copy; the source file is unchanged. No third-party
logo appears in it (app names are text). The Launcher and Apps pictures were not used: they show app logos.
+430
View File
@@ -0,0 +1,430 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""build.py — the Felhom.eu Facebook Page pictures: two profile pictures, three covers, one preview page.
Run: python3 marketing/facebook/build.py
Exit: 0 every image built and every check passed · 1 a check failed · 2 a render failed
Renderer: Pillow only (shapes, text, compositing). Text is set in the website's own Plus Jakarta Sans
(website/assets/fonts/*.woff2), which Pillow reads through FreeType. NOT librsvg: tried 2026-10-08 — pango fell back
to DejaVu for the woff2 fonts, and logo.svg's own lettering is live <text> in 'M+ 2c' / 'Vremena Grotesk', fonts
DooPlex does not have, so the SVG renders with the wrong letters. The logo master is therefore website/assets/logo.png
(645 x 408, the file the site itself shows), placed unscaled.
Checks (each prints its numbers; a failure exits 1):
circle every logo pixel of a profile picture lies inside radius 0.42 x width of the centre (8 % margin).
Shown failing first on the shape the Page has today (logo.png, full size, on a square).
safe every pixel of a cover's CONTENT layer (text, logo, drawings — not the background) lies inside the
area both desktop and phone show, and outside the places the profile circle covers.
size each PNG's size is read back from the file, never taken from the constants here.
Sizes (graded READ, second-hand — Meta's own help page needs a browser; see README.md for the URLs):
cover shown 820 x 312 on computers, 640 x 360 on phones; built at 2 x = 1640 x 624.
profile shown 176 px (computer) / 196 px (phone, older layouts) — built at the size where logo.png fits unscaled.
profile circle over the cover: computer 16 px from the left, 176 px from the top (176 px circle);
phone 24 px from the left, 196 px from the top (140 px circle).
"""
import base64
import math
import os
import sys
from PIL import Image, ImageDraw, ImageFilter, ImageFont
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE))
sys.path.insert(0, HERE)
ASSETS = os.path.join(ROOT, "website", "assets")
OUT = os.path.join(HERE, "out")
LOGO_PNG = os.path.join(ASSETS, "logo.png")
SHOT = os.path.join(ASSETS, "dashboard-backups-hu.webp")
PJS = os.path.join(ASSETS, "fonts", "pjs-latin.woff2")
PJS_EXT = os.path.join(ASSETS, "fonts", "pjs-latin-ext.woff2")
def hexc(h, a=255):
h = h.lstrip("#")
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), a)
# website/assets/site.css :root — design system v2 tokens, verbatim (the site is dark-only).
BG0, BG1, BG2 = hexc("#0A1220"), hexc("#0F1B2E"), hexc("#16263F")
LINE = hexc("#22344F")
TEXT1, TEXT2, TEXT3 = hexc("#EDF2F9"), hexc("#94A6BF"), hexc("#5E7392")
BLUE, BLUE_BRIGHT = hexc("#0083D8"), hexc("#2EA8F5")
WHITE = hexc("#FFFFFF")
# The site's headline (index.html hero), from escapes (brief section 9.7).
L1 = "Saj\u00e1t felh\u0151d,"
L2 = "saj\u00e1t szab\u00e1lyaid"
SLOGAN = L1 + " " + L2
DOMAIN = "felhom.eu"
CIRCLE_R = 0.42 # brief §7 A: the check radius, as a fraction of the width
LOGO_R = 0.40 # where the logo's farthest pixel is placed (inside the check radius)
CW, CH = 1640, 624 # cover, 2 x the 820 x 312 computer display
SCALE = CW / 820.0
# Phone shows the full height at 640:360 → the centre CW_PHONE pixels of the width.
CW_PHONE = CH * 640.0 / 360.0
PHONE_X0 = (CW - CW_PHONE) / 2.0
MARGIN = 40
SAFE = (int(math.ceil(PHONE_X0 + MARGIN)), MARGIN, int(CW - PHONE_X0 - MARGIN), CH - MARGIN)
PAD = 24
DESK_CIRCLE = (16 * SCALE, 176 * SCALE, 176 * SCALE) # x, y, diameter in cover pixels
PHONE_K = CH / 360.0
PHONE_CIRCLE = (PHONE_X0 + 24 * PHONE_K, 196 * PHONE_K, 140 * PHONE_K)
QUIET = [(0, c[1] - PAD, c[0] + c[2] + PAD, CH) for c in (DESK_CIRCLE, PHONE_CIRCLE)]
FAILS = []
def check(ok, msg):
print((" ok " if ok else " FAIL ") + msg)
if not ok:
FAILS.append(msg)
# ------------------------------------------------------------------ text in the site font
_fonts = {}
def font(size, weight, ext):
key = (size, weight, ext)
if key not in _fonts:
f = ImageFont.truetype(PJS_EXT if ext else PJS, size)
f.set_variation_by_name(weight)
_fonts[key] = f
return _fonts[key]
def runs(text):
"""Split into runs by the CSS unicode-range of the two woff2 files (latin-ext = U+0100–02BA)."""
out = []
for ch in text:
ext = 0x100 <= ord(ch) <= 0x2BA
if out and out[-1][0] == ext:
out[-1][1] += ch
else:
out.append([ext, ch])
return out
def text_width(text, size, weight):
return sum(font(size, weight, e).getlength(s) for e, s in runs(text))
def draw_text(img, x, y, text, size, weight, fill, align="left"):
"""Draw at baseline y; x is the left edge, or the centre with align='center'. Returns the width."""
w = text_width(text, size, weight)
if align == "center":
x -= w / 2.0
d = ImageDraw.Draw(img)
for e, s in runs(text):
f = font(size, weight, e)
d.text((x, y), s, font=f, fill=fill, anchor="ls")
x += f.getlength(s)
return w
def fit_size(text, weight, max_w, start):
size = start
while text_width(text, size, weight) > max_w:
size -= 1
return size
# ------------------------------------------------------------------ the logo
def alpha_points(img, thr=16):
a = img.getchannel("A")
w, h = img.size
px = a.load()
return [(x, y) for y in range(h) for x in range(w) if px[x, y] > thr]
def outside_circle(img, bg, r_frac):
"""Count pixels that differ from the background and lie outside radius r_frac x width of the centre."""
w, h = img.size
cx, cy, r = (w - 1) / 2.0, (h - 1) / 2.0, r_frac * w
rgb = img.convert("RGB").load()
n = 0
for y in range(h):
dy2 = (y - cy) ** 2
for x in range(w):
p = rgb[x, y]
if max(abs(p[0] - bg[0]), abs(p[1] - bg[1]), abs(p[2] - bg[2])) > 24 and (x - cx) ** 2 + dy2 > r * r:
n += 1
return n
def today_shape():
"""The Page today: logo.png at full size, unscaled, centred on a white square of its own width."""
src = Image.open(LOGO_PNG).convert("RGBA")
w, h = src.size
sq = Image.new("RGBA", (w, w), WHITE)
sq.alpha_composite(src, (0, (w - h) // 2))
return sq
def profile_width():
"""The square size at which logo.png fits radius LOGO_R x width UNSCALED (never upsampled), at least 720."""
src = Image.open(LOGO_PNG).convert("RGBA")
pts = alpha_points(src)
xs, ys = [p[0] for p in pts], [p[1] for p in pts]
cx, cy = (min(xs) + max(xs)) / 2.0, (min(ys) + max(ys)) / 2.0
dmax = max(math.hypot(x - cx, y - cy) for x, y in pts) + 1
return max(720, int(math.ceil(dmax / LOGO_R / 2.0)) * 2), (cx, cy)
def build_profile(bg, name):
w, (cx, cy) = profile_width()
logo = Image.open(LOGO_PNG).convert("RGBA")
img = Image.new("RGBA", (w, w), bg)
img.alpha_composite(logo, (int(round(w / 2.0 - cx)), int(round(w / 2.0 - cy))))
path = os.path.join(OUT, name)
img.convert("RGB").save(path, optimize=True)
return path
# ------------------------------------------------------------------ covers
def background(glow_at=(0.78, 0.25)):
"""bg-0 with the site's 50 px grid (3 % blue, index.html) and the og-image's soft blue glow; top blue rule."""
img = Image.new("RGBA", (CW, CH), BG0)
glow = Image.new("RGBA", (CW, CH), (0, 0, 0, 0))
gd = ImageDraw.Draw(glow)
gx, gy, gr = glow_at[0] * CW, glow_at[1] * CH, 520
gd.ellipse((gx - gr, gy - gr, gx + gr, gy + gr), fill=(0, 131, 216, 46))
img.alpha_composite(glow.filter(ImageFilter.GaussianBlur(180)))
grid = Image.new("RGBA", (CW, CH), (0, 0, 0, 0))
d = ImageDraw.Draw(grid)
step = int(50 * SCALE)
for x in range(0, CW, step):
d.line((x, 0, x, CH), fill=(0, 131, 216, 9), width=2)
for y in range(0, CH, step):
d.line((0, y, CW, y), fill=(0, 131, 216, 9), width=2)
img.alpha_composite(grid)
ImageDraw.Draw(img).rectangle((0, 0, CW, 8), fill=BLUE)
return img
def content_a():
c = Image.new("RGBA", (CW, CH), (0, 0, 0, 0))
mid = (SAFE[0] + SAFE[2]) / 2.0
size = fit_size(SLOGAN, "ExtraBold", (SAFE[2] - SAFE[0]) - 120, 66)
w1 = text_width(L1 + " ", size, "ExtraBold")
w = text_width(SLOGAN, size, "ExtraBold")
x0 = mid - w / 2.0
draw_text(c, x0, 238, L1 + " ", size, "ExtraBold", TEXT1)
draw_text(c, x0 + w1, 238, L2, size, "ExtraBold", BLUE_BRIGHT)
draw_text(c, mid, 300, DOMAIN, 34, "SemiBold", TEXT2, align="center")
return c
def rack(d, x, y, w, h, fill, dot):
"""Three stacked server units with status dots — the logo's own motif, drawn as plain shapes."""
uh = (h - 2 * 8) / 3.0
for i in range(3):
ty = y + i * (uh + 8)
d.rectangle((x, ty, x + w, ty + uh), fill=fill)
for j in range(3):
cx = x + 20 + j * 22
d.ellipse((cx - 5, ty + uh / 2 - 5, cx + 5, ty + uh / 2 + 5), fill=dot)
def tile(d, cx, cy, s, kind):
"""A plain app tile (no third-party logo): a square with a simple generic glyph."""
x0, y0, x1, y1 = cx - s / 2, cy - s / 2, cx + s / 2, cy + s / 2
d.rectangle((x0, y0, x1, y1), fill=BG2, outline=LINE, width=3)
g = s * 0.28
if kind == "photo":
d.polygon([(cx - g, cy + g * 0.7), (cx - g * 0.2, cy - g * 0.5), (cx + g * 0.3, cy + g * 0.2),
(cx + g * 0.6, cy - g * 0.1), (cx + g, cy + g * 0.7)], fill=BLUE_BRIGHT)
d.ellipse((cx + g * 0.35, cy - g, cx + g * 0.85, cy - g * 0.5), fill=TEXT2)
elif kind == "doc":
d.rectangle((cx - g * 0.75, cy - g, cx + g * 0.75, cy + g), outline=TEXT1, width=5)
for i in range(3):
yy = cy - g * 0.45 + i * g * 0.45
d.line((cx - g * 0.45, yy, cx + g * 0.45, yy), fill=BLUE_BRIGHT, width=5)
elif kind == "play":
d.polygon([(cx - g * 0.6, cy - g), (cx + g, cy), (cx - g * 0.6, cy + g)], fill=BLUE_BRIGHT)
elif kind == "lock":
d.arc((cx - g * 0.6, cy - g * 1.2, cx + g * 0.6, cy), 180, 360, fill=TEXT1, width=6)
d.rectangle((cx - g * 0.85, cy - g * 0.6, cx + g * 0.85, cy + g * 0.9), fill=TEXT1)
d.ellipse((cx - g * 0.18, cy - g * 0.1, cx + g * 0.18, cy + g * 0.26), fill=BG2)
elif kind == "folder":
d.polygon([(cx - g, cy - g * 0.7), (cx - g * 0.2, cy - g * 0.7), (cx, cy - g * 0.4), (cx + g, cy - g * 0.4),
(cx + g, cy + g * 0.8), (cx - g, cy + g * 0.8)], fill=BLUE_BRIGHT)
def content_b():
c = Image.new("RGBA", (CW, CH), (0, 0, 0, 0))
d = ImageDraw.Draw(c)
tx = SAFE[0] + 24 # text stays above the profile circles (y < 316)
draw_text(c, tx, 170, L1, 64, "ExtraBold", TEXT1)
draw_text(c, tx, 244, L2, 64, "ExtraBold", BLUE_BRIGHT)
draw_text(c, tx, 290, DOMAIN, 32, "SemiBold", TEXT2)
# the home with its server, on the right
hx, hy, hw = 1010, 300, 220 # house body: left, top, width
tiles = [(975, 110, "photo"), (1140, 92, "doc"), (1260, 210, "play"), (1260, 400, "lock"), (970, 520, "folder")]
for x, y, _ in tiles:
d.line((hx + hw / 2, hy + 90, x, y), fill=(46, 168, 245, 110), width=4)
d.rectangle((hx, hy, hx + hw, hy + 190), fill=BG1, outline=BLUE_BRIGHT, width=6)
d.polygon([(hx - 30, hy + 4), (hx + hw / 2, hy - 120), (hx + hw + 30, hy + 4)], fill=BG1, outline=BLUE_BRIGHT)
d.line([(hx - 30, hy + 4), (hx + hw / 2, hy - 120), (hx + hw + 30, hy + 4)], fill=BLUE_BRIGHT, width=6, joint="curve")
rack(d, hx + 40, hy + 40, hw - 80, 110, BLUE, TEXT1)
for x, y, kind in tiles:
tile(d, x, y, 92, kind)
return c
def content_c():
c = Image.new("RGBA", (CW, CH), (0, 0, 0, 0))
d = ImageDraw.Draw(c)
tx = SAFE[0] + 24
draw_text(c, tx, 180, L1, 52, "ExtraBold", TEXT1)
draw_text(c, tx, 246, L2, 52, "ExtraBold", BLUE_BRIGHT)
draw_text(c, tx, 292, DOMAIN, 30, "SemiBold", TEXT2)
shot = Image.open(SHOT).convert("RGBA")
# The domain chip next to the title names the demo box; covered with the page colour (copy only, source unchanged).
ImageDraw.Draw(shot).rectangle((684, 32, 826, 70), fill=hexc("#0B1322"))
sw = 470
sh = int(round(sw * shot.height / shot.width))
shot = shot.resize((sw, sh), Image.LANCZOS)
fx, fy, b = 800, 110, 14 # frame left/top, bezel
d.rectangle((fx, fy, fx + sw + 2 * b, fy + sh + 2 * b), fill=BG2, outline=LINE, width=3)
c.alpha_composite(shot, (fx + b, fy + b))
base_y = fy + sh + 2 * b
d.polygon([(fx - 30, base_y + 22), (fx + sw + 2 * b + 30, base_y + 22), (fx + sw + 2 * b, base_y),
(fx, base_y)], fill=LINE)
return c
def check_safe(name, content):
pts = alpha_points(content.resize((CW // 4, CH // 4)), thr=8)
xs = [p[0] * 4 for p in pts]
ys = [p[1] * 4 for p in pts]
bbox = (min(xs), min(ys), max(xs) + 3, max(ys) + 3)
inside = bbox[0] >= SAFE[0] and bbox[1] >= SAFE[1] and bbox[2] <= SAFE[2] and bbox[3] <= SAFE[3]
check(inside, "%s: content box %s inside the safe area %s" % (name, bbox, SAFE))
hits = 0
for q in QUIET:
hits += sum(1 for x, y in zip(xs, ys) if q[0] <= x <= q[2] and q[1] <= y <= q[3])
check(hits == 0, "%s: %d content pixels where the profile circle sits (computer or phone)" % (name, hits))
def build_cover(name, content, glow):
img = background(glow)
img.alpha_composite(content)
path = os.path.join(OUT, name)
img.convert("RGB").save(path, optimize=True)
return path
# ------------------------------------------------------------------ preview
def b64(path):
with open(path, "rb") as fh:
return "data:image/png;base64," + base64.b64encode(fh.read()).decode()
def preview(profiles, covers):
dk, ph = DESK_CIRCLE, PHONE_CIRCLE
sx0, sy0, sx1, sy1 = SAFE
pct = lambda v, tot: "%.3f%%" % (100.0 * v / tot) # noqa: E731
prof_html = "".join(
'<figure><div class="row"><img class="pc" src="%s" alt=""><img class="pc sm" src="%s" alt="">'
'<img class="pc xs" src="%s" alt=""></div><figcaption>%s — 176 px, 96 px, 40 px</figcaption></figure>'
% (b64(p), b64(p), b64(p), label) for label, p in profiles)
cov = []
rec = profiles[0][1]
for label, p in covers:
src = b64(p)
safe = ('<div class="safe" style="left:%s;top:%s;width:%s;height:%s"></div>'
% (pct(sx0, CW), pct(sy0, CH), pct(sx1 - sx0, CW), pct(sy1 - sy0, CH)))
desk = ('<div class="desk"><img src="%s" alt="">%s<img class="ov" src="%s" alt="" '
'style="left:%s;top:%s;width:%s"></div>'
% (src, safe, b64(rec), pct(dk[0], CW), pct(dk[1], CH), pct(dk[2], CW)))
crop_l = PHONE_X0 / CW_PHONE * 100
phone = ('<div class="phone"><div class="crop"><img src="%s" alt="" style="width:%.3f%%;left:-%.3f%%">'
'</div><img class="ov" src="%s" alt="" style="left:%s;top:%s;width:%s"></div>'
% (src, CW / CW_PHONE * 100, crop_l, b64(rec), pct(ph[0] - PHONE_X0, CW_PHONE),
pct(ph[1], CH), pct(ph[2], CW_PHONE)))
cov.append('<section><h2>%s</h2><p class="k">Computer (820 x 312), dashed = safe area (preview only)</p>%s'
'<p class="k">Phone (640 x 360 crop)</p>%s</section>' % (label, desk, phone))
html = """<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Facebook Page pictures</title>
<style>
:root{--bg-0:#0A1220;--bg-1:#0F1B2E;--line:#22344F;--text-1:#EDF2F9;--text-2:#94A6BF;--blue:#0083D8}
body{margin:0;background:var(--bg-0);color:var(--text-1);font:15px/1.5 -apple-system,'Segoe UI',sans-serif;padding:24px 16px}
main{max-width:860px;margin:0 auto}h1{font-size:22px;margin:0 0 4px}h2{font-size:17px;margin:32px 0 8px}
.k{color:var(--text-2);margin:12px 0 6px;font-size:13px}figure{margin:16px 0}.row{display:flex;gap:16px;align-items:center;flex-wrap:wrap}
.pc{width:176px;height:176px;border-radius:50%%;border:4px solid #fff;display:block}.sm{width:96px;height:96px;border-width:3px}
.xs{width:40px;height:40px;border-width:1px}figcaption{color:var(--text-2);font-size:13px;margin-top:6px}
.desk{position:relative;max-width:820px;margin-bottom:56px}.desk>img:first-child{width:100%%;display:block}
.ov{position:absolute;border-radius:50%%;border:3px solid #fff;box-sizing:border-box}
.safe{position:absolute;border:2px dashed #E0A93E;box-sizing:border-box;pointer-events:none}
.phone{position:relative;width:min(360px,100%%);aspect-ratio:640/360;margin-bottom:48px}
.crop{position:absolute;inset:0;overflow:hidden}.crop img{position:absolute;top:0;height:100%%}
</style></head><body><main>
<h1>Felhom.eu - Facebook pictures</h1>
<p class="k">Profile picture: the DARK background is recommended. The logo's lettering is white with a thin navy edge,
so on white it fades at 40 px; on dark it reads solid, as on the website. The covers below show the dark one.</p>
<h2>Profile picture</h2>%s
%s
</main></body></html>
""" % (prof_html, "".join(cov))
path = os.path.join(OUT, "preview.html")
with open(path, "w", encoding="utf-8") as fh:
fh.write(html)
return path
# ------------------------------------------------------------------ main
def main():
os.makedirs(OUT, exist_ok=True)
print("circle check (radius %.2f x width)" % CIRCLE_R)
try:
before = today_shape()
n0 = outside_circle(before, WHITE, CIRCLE_R)
print(" today's shape (logo.png %dx%d on a %d square): %d logo pixels outside the circle — expected > 0"
% (Image.open(LOGO_PNG).width, Image.open(LOGO_PNG).height, before.width, n0))
check(n0 > 0, "the check convicts today's shape (control)")
prof = [("profile-white.png", WHITE), ("profile-dark.png", BG0)]
built = []
for name, bg in prof:
p = build_profile(bg, name)
n = outside_circle(Image.open(p), bg, CIRCLE_R)
check(n == 0, "%s: %d logo pixels outside the circle" % (name, n))
built.append(p)
print("safe-area check (computer 820x312 + phone 640x360; profile circles kept clear)")
covers = [("cover-a.png", content_a(), (0.78, 0.25)), ("cover-b.png", content_b(), (0.70, 0.45)),
("cover-c.png", content_c(), (0.66, 0.40))]
cpaths = []
for name, content, glow in covers:
check_safe(name, content)
cpaths.append(build_cover(name, content, glow))
except RuntimeError as e:
print("RENDER FAILED: %s" % e)
return 2
print("sizes read back from the files")
for p in built + cpaths:
w, h = Image.open(p).size
want = (profile_width()[0],) * 2 if "profile" in p else (CW, CH)
check((w, h) == want, "%s: %d x %d" % (os.path.basename(p), w, h))
pv = preview([("Dark background (recommended)", built[1]), ("White background", built[0])],
[("A - calm", cpaths[0]), ("B - the idea", cpaths[1]), ("C - the product", cpaths[2])])
print("preview: %s (%d KB)" % (os.path.relpath(pv, ROOT), os.path.getsize(pv) // 1024))
if FAILS:
print("FAILED: %d check(s)" % len(FAILS))
return 1
print("all checks passed")
return 0
if __name__ == "__main__":
sys.exit(main())
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB