diff --git a/CLAUDE.md b/CLAUDE.md index 2b97ff13..a9ad9d92 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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-.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/`. diff --git a/REPORT-facebook-page-pictures.md b/REPORT-facebook-page-pictures.md new file mode 100644 index 00000000..0e86ddb7 --- /dev/null +++ b/REPORT-facebook-page-pictures.md @@ -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. diff --git a/STATUS.md b/STATUS.md index cb7d1070..5199400e 100644 --- a/STATUS.md +++ b/STATUS.md @@ -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 diff --git a/documentation/backlog/OPEN-ITEMS.md b/documentation/backlog/OPEN-ITEMS.md index 7a5f7c8e..8150f1fe 100644 --- a/documentation/backlog/OPEN-ITEMS.md +++ b/documentation/backlog/OPEN-ITEMS.md @@ -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=`, „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) diff --git a/marketing/CHANGELOG.md b/marketing/CHANGELOG.md new file mode 100644 index 00000000..209d9e13 --- /dev/null +++ b/marketing/CHANGELOG.md @@ -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). diff --git a/marketing/facebook/COPY.md b/marketing/facebook/COPY.md new file mode 100644 index 00000000..b755b10b --- /dev/null +++ b/marketing/facebook/COPY.md @@ -0,0 +1,85 @@ +# Felhom.eu Facebook Page — texts to paste + +Every claim cites the website page it comes from (``, 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) + + + + +``` +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) + + + +``` +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 + + + +``` +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` + + + +``` +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` + + + +``` +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` + + + +``` +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 +``` diff --git a/marketing/facebook/README.md b/marketing/facebook/README.md new file mode 100644 index 00000000..13347b24 --- /dev/null +++ b/marketing/facebook/README.md @@ -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. diff --git a/marketing/facebook/build.py b/marketing/facebook/build.py new file mode 100644 index 00000000..34ad5d6c --- /dev/null +++ b/marketing/facebook/build.py @@ -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 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( + '
' + '
%s — 176 px, 96 px, 40 px
' + % (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 = ('
' + % (pct(sx0, CW), pct(sy0, CH), pct(sx1 - sx0, CW), pct(sy1 - sy0, CH))) + desk = ('
%s
' + % (src, safe, b64(rec), pct(dk[0], CW), pct(dk[1], CH), pct(dk[2], CW))) + crop_l = PHONE_X0 / CW_PHONE * 100 + phone = ('
' + '
' + % (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('

%s

Computer (820 x 312), dashed = safe area (preview only)

%s' + '

Phone (640 x 360 crop)

%s
' % (label, desk, phone)) + html = """ + +Facebook Page pictures +
+

Felhom.eu - Facebook pictures

+

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.

+

Profile picture

%s +%s +
+""" % (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()) diff --git a/marketing/facebook/out/cover-a.png b/marketing/facebook/out/cover-a.png new file mode 100644 index 00000000..a0720446 Binary files /dev/null and b/marketing/facebook/out/cover-a.png differ diff --git a/marketing/facebook/out/cover-b.png b/marketing/facebook/out/cover-b.png new file mode 100644 index 00000000..5c9531af Binary files /dev/null and b/marketing/facebook/out/cover-b.png differ diff --git a/marketing/facebook/out/cover-c.png b/marketing/facebook/out/cover-c.png new file mode 100644 index 00000000..6e6fe73b Binary files /dev/null and b/marketing/facebook/out/cover-c.png differ diff --git a/marketing/facebook/out/preview.html b/marketing/facebook/out/preview.html new file mode 100644 index 00000000..edd5181e --- /dev/null +++ b/marketing/facebook/out/preview.html @@ -0,0 +1,22 @@ + + +Facebook Page pictures +
+

Felhom.eu - Facebook pictures

+

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.

+

Profile picture

Dark background (recommended) — 176 px, 96 px, 40 px
White background — 176 px, 96 px, 40 px
+

A - calm

Computer (820 x 312), dashed = safe area (preview only)

Phone (640 x 360 crop)

B - the idea

Computer (820 x 312), dashed = safe area (preview only)

Phone (640 x 360 crop)

C - the product

Computer (820 x 312), dashed = safe area (preview only)

Phone (640 x 360 crop)

+
diff --git a/marketing/facebook/out/profile-dark.png b/marketing/facebook/out/profile-dark.png new file mode 100644 index 00000000..bb4b2607 Binary files /dev/null and b/marketing/facebook/out/profile-dark.png differ diff --git a/marketing/facebook/out/profile-white.png b/marketing/facebook/out/profile-white.png new file mode 100644 index 00000000..87774506 Binary files /dev/null and b/marketing/facebook/out/profile-white.png differ