# -*- coding: utf-8 -*-
"""Generates the AfriX mobile concept artboards from one shared token system."""
import os, json, glob
OUT = os.path.dirname(os.path.abspath(__file__))
FONTS = ('')
CSS = """
*{box-sizing:border-box}
body{margin:0;background:transparent}
a{color:var(--accent);text-decoration:none}
a:hover{color:var(--ink)}
.afx{width:390px;height:844px;overflow:hidden;position:relative;display:flex;flex-direction:column;
font-family:"IBM Plex Sans","Segoe UI",system-ui,sans-serif;background:var(--bg);color:var(--ink);
-webkit-font-smoothing:antialiased;font-size:16px;line-height:1.5}
.afx[data-t="light"]{--bg:#F5F7FB;--surface:#FFFFFF;--surface2:#E8EEF7;--hair:#D4DCE8;--border:#6B7789;
--ink:#0A0A0A;--ink2:#3F4A5C;--ink3:#5B6472;--accent:#1E3A8A;--ok:#046C4A;--danger:#991B1B;
--fill:#1E40AF;--onfill:#FFFFFF;--fillbd:transparent}
.afx[data-t="dark"]{--bg:#0A1020;--surface:#121A2E;--surface2:#1A2440;--hair:#263251;--border:#6B7C9E;
--ink:#EAEEF6;--ink2:#A8B4CA;--ink3:#8391AC;--accent:#9DBBF7;--ok:#34D399;--danger:#F4A9A0;
--fill:#2B4FD1;--onfill:#FFFFFF;--fillbd:#6B7C9E}
.mono{font-family:"IBM Plex Mono",ui-monospace,Menlo,monospace;font-variant-numeric:tabular-nums}
.cond{font-family:"IBM Plex Sans Condensed","IBM Plex Sans",system-ui,sans-serif}
.safe{height:44px;flex:0 0 auto}
.safeb{height:20px;flex:0 0 auto}
/* app bar */
.appbar{height:56px;flex:0 0 auto;display:flex;align-items:center;gap:8px;padding:0 14px;
border-bottom:1px solid var(--hair)}
.wordmark{font-family:"IBM Plex Sans Condensed","IBM Plex Sans",system-ui,sans-serif;font-weight:700;
font-size:21px;letter-spacing:-.01em;margin-inline-end:auto}
.ic{position:relative;width:40px;height:40px;border-radius:50%;background:var(--surface2);
border:1px solid var(--hair);color:var(--ink2);display:flex;align-items:center;justify-content:center;flex:0 0 auto}
.ic::after{content:"";position:absolute;inset:-2px}
.ic.plain{background:none;border-color:transparent;border-radius:0;width:44px;height:44px}
.badge{position:absolute;top:-3px;inset-inline-end:-3px;min-width:18px;height:18px;padding:0 4px;
border-radius:999px;background:var(--danger);color:var(--bg);font-family:"IBM Plex Mono",monospace;
font-size:9.5px;font-weight:600;line-height:1;display:flex;align-items:center;justify-content:center;
border:2px solid var(--bg)}
.badge.dot{min-width:10px;height:10px;padding:0;top:-1px;inset-inline-end:-1px}
/* scope rail */
.rail{height:48px;flex:0 0 auto;display:flex;align-items:stretch;overflow-x:auto;overflow-y:hidden;
scrollbar-width:none;-ms-overflow-style:none;border-bottom:1px solid var(--hair)}
.rail::-webkit-scrollbar{display:none}
.rail-i{display:flex;align-items:center;gap:6px;padding:0 11px;border:0;background:none;cursor:pointer;
border-bottom:2px solid transparent;color:var(--ink3);font-family:inherit;font-size:13px;font-weight:500;
white-space:nowrap;flex:0 0 auto}
.rail-i[data-sel="1"]{color:var(--ink);font-weight:600;border-bottom-color:var(--accent)}
.gl{display:grid;grid-template-columns:repeat(7,1fr);gap:1px;width:22px;height:25px;flex:0 0 auto;direction:ltr}
.glw{position:relative;display:inline-flex;flex:0 0 auto}
[dir="rtl"] .sealsv{transform:scaleX(-1)}
.geo{direction:ltr}
.sealbtn{position:relative;display:inline-flex;flex:0 0 auto;cursor:pointer}
.sealbtn::after{content:"";position:absolute;inset:-14px}
.arw{display:inline-block}
[dir="rtl"] .arw{transform:scaleX(-1)}
.striplink{font-family:inherit;font-size:inherit;font-weight:500;color:var(--accent);background:none;
border:0;border-bottom:1px solid var(--accent);padding:0;cursor:pointer;position:relative;margin-inline-start:auto}
.striplink::after{content:"";position:absolute;inset:-14px -8px}
.gl i{display:block}
.rail-i[data-sel="1"] .gl{color:var(--accent)}
.rail-i .gl{color:var(--ink3)}
/* feed */
.feed{flex:1 1 auto;overflow-y:auto;overflow-x:hidden;-webkit-overflow-scrolling:touch;
scrollbar-width:none;-ms-overflow-style:none;overscroll-behavior:contain}
.feed::-webkit-scrollbar{display:none}
.sheetscroll{flex:1 1 auto;overflow-y:auto;scrollbar-width:none}
.sheetscroll::-webkit-scrollbar{display:none}
.card{display:grid;grid-template-columns:40px minmax(0,1fr);gap:12px;padding:12px 16px;
border-bottom:1px solid var(--hair)}
.av{width:40px;height:40px;border-radius:50%;background:var(--surface2);overflow:hidden;flex:0 0 auto;
display:block;aspect-ratio:1}
.av img,.av svg{border-radius:50%}
.meta{display:flex;align-items:center;gap:5px;min-width:0}
.nm{font-weight:600;font-size:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
.hd{color:var(--ink3);font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;direction:ltr;unicode-bidi:isolate}
.ts{font-family:"IBM Plex Mono",monospace;font-variant-numeric:tabular-nums;font-size:12px;color:var(--ink3);
margin-inline-start:auto;flex:0 0 auto}
.ptxt{margin:3px 0 8px;font-size:16px;line-height:1.5;color:var(--ink);unicode-bidi:plaintext;text-align:start}
.pfoot{display:flex;align-items:center;gap:20px;color:var(--ink3)}
/* the feed's compose prompt — 44 + gap, so the avatar column lines up with the
cards under it, and the whole row is one target */
.cprompt{display:grid;grid-template-columns:44px minmax(0,1fr) auto;align-items:center;gap:12px;
width:100%;text-align:start;padding:12px 16px;border-bottom:1px solid var(--hair);
background:none;color:inherit}
.cptext{font-size:17px;color:var(--ink3);min-width:0;overflow:hidden;text-overflow:ellipsis;
white-space:nowrap}
.cpbtn{display:inline-flex;align-items:center;height:34px;padding:0 14px;background:var(--fill);
color:var(--onfill);border:1px solid var(--fillbd);font-size:13px;font-weight:600}
.composer-inline{border-bottom:1px solid var(--hair)}
.cbar-i{display:flex;align-items:center;gap:8px;padding:8px 16px;min-height:56px}
.ownstate{margin:0;padding:10px 16px 0;font-family:"IBM Plex Mono",monospace;font-size:10px;
letter-spacing:.1em;color:var(--ink3);direction:ltr;unicode-bidi:isolate}
/* Bidi (spec I18N-4). Two tools. Content takes its direction from its own first strong
character, because an English post on an Arabic feed is normal in a four-language
product. Chrome that is always left-to-right is isolated explicitly, because it can
carry no strong character at all ("34 / 500") and so cannot be inferred. `.hd` already
did this; the rest of the readouts need it for the same reason. */
p,li,dd,td,th,h1,h2,h3,h4,blockquote,figcaption,label,em,q,.nm{unicode-bidi:plaintext}
.ts,.pf,.vdur,.geo,.badge{direction:ltr;unicode-bidi:isolate}
.pf{display:flex;align-items:center;gap:5px;font-family:"IBM Plex Mono",monospace;font-size:12px;
font-variant-numeric:tabular-nums}
.geo{display:inline-flex;align-items:center;gap:4px;font-family:"IBM Plex Mono",monospace;font-size:10px;
letter-spacing:.05em;color:var(--ink3);border:1px solid var(--hair);padding:1px 5px;flex:0 0 auto}
/* voice */
.voice{display:flex;align-items:center;gap:10px;border:1px solid var(--hair);background:var(--surface2);
padding:8px 10px;margin:4px 0 8px;height:48px}
.play{width:32px;height:32px;flex:0 0 auto;border:1px solid var(--border);background:none;color:var(--ink);
display:flex;align-items:center;justify-content:center;cursor:pointer;position:relative}
.play::after{content:"";position:absolute;inset:-6px}
.wave{display:flex;align-items:center;gap:2px;height:26px;flex:1 1 auto;overflow:hidden}
.wave i{flex:1 1 auto;min-width:2px;display:block}
.vdur{font-family:"IBM Plex Mono",monospace;font-variant-numeric:tabular-nums;font-size:12px;color:var(--ink2);flex:0 0 auto}
/* media */
.tone{position:relative;width:100%;border:1px solid var(--hair);display:flex;align-items:center;
justify-content:center;margin:4px 0 8px}
.loadchip{background:#0A1020;border:1px solid #FFFFFF;color:#FFFFFF;font-family:"IBM Plex Mono",monospace;
font-size:11px;padding:0 12px;height:44px;display:flex;align-items:center;cursor:pointer}
/* strips */
.strip{display:flex;align-items:center;gap:8px;padding:11px 16px;min-height:44px;background:var(--surface2);
border-bottom:1px solid var(--hair);font-family:"IBM Plex Mono",monospace;font-size:11.5px;color:var(--ink2);flex:0 0 auto}
.dot{width:6px;height:6px;flex:0 0 auto;background:var(--danger)}
.tag{font-family:"IBM Plex Mono",monospace;font-size:10px;letter-spacing:.06em;border:1px solid var(--border);
color:var(--ink2);padding:1px 5px;flex:0 0 auto}
/* controls */
.btn{font-family:inherit;font-size:15px;font-weight:600;height:44px;padding:0 18px;border:1px solid var(--fillbd);
background:var(--fill);color:var(--onfill);cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px}
.btn.ghost{background:none;color:var(--ink);border-color:var(--border)}
.btn.dis{background:none;color:var(--ink3);border-color:var(--border)}
/* tab bar */
.tabs{height:62px;flex:0 0 auto;display:flex;align-items:center;border-top:1px solid var(--hair);
background:var(--bg);position:relative}
.tab{position:relative;flex:1 1 0;display:flex;align-items:center;justify-content:center;height:62px;
color:var(--ink3)}
.tab[data-sel="1"]{color:var(--accent)}
.tab[data-sel="1"] svg{stroke-width:2.1;fill:currentColor;fill-opacity:.14}
.tab[data-sel="1"]::before{content:"";position:absolute;top:-1px;inset-inline:22px;height:2px;background:var(--accent)}
.navcta{flex:0 0 auto;width:54px;height:54px;border-radius:50%;background:var(--fill);color:#FFFFFF;
display:flex;align-items:center;justify-content:center;margin:-16px 6px 0;border:3px solid var(--bg)}
.fab{position:absolute;inset-inline-end:16px;bottom:100px;width:56px;height:56px;background:var(--fill);
border:1px solid var(--fillbd);color:var(--onfill);display:flex;align-items:center;justify-content:center;z-index:5}
.eyebrow{font-family:"IBM Plex Mono",monospace;font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink3)}
.h1{font-family:"IBM Plex Sans Condensed","IBM Plex Sans",system-ui,sans-serif;font-weight:700;font-size:26px;
line-height:1.1;letter-spacing:-.01em;margin:0}
.sect{padding:14px 16px;border-bottom:1px solid var(--hair)}
.ar{font-family:"IBM Plex Sans Arabic","IBM Plex Sans",system-ui,sans-serif}
"""
# The continent matrix, drawn rather than encoded (spec GEO-2). 7 wide x 8 tall.
# `.` ocean, N/W/E/S the four regions, C central (unassigned; not one of the six).
# Madagascar is the lone E on row 6, across a one-cell channel.
#
# It replaced a 5x5 of eighteen cells, which was a blob: at that resolution
# nothing distinguishes Africa from any other landmass. These are the four marks
# that make the shape legible at 22px — the wide Saharan north, the West African
# bulge above the Gulf of Guinea, the Horn reaching east, and the taper to the
# Cape.
GRID_COLS, GRID_ROWS = 7, 8
_MAP = (".NNNNN."
"NNNNNN."
"WWWCCEE"
".WWCCEE"
"..CCCE."
"..SSS.E"
"..SSS.."
"...S...")
_where = lambda ch: [i + 1 for i, c in enumerate(_MAP) if c == ch]
CENTRAL = _where("C")
CONT = sorted(_where("N") + _where("W") + _where("E") + _where("S") + CENTRAL)
MASKS = {
"all": CONT,
"north": _where("N"),
"west": _where("W"),
"east": _where("E"),
"south": _where("S"),
"dia": [],
}
SCOPES = [("all","All Africa"),("north","North"),("west","West"),("east","East"),
("south","Southern"),("dia","Diaspora")]
def glyph(key, size=20, gap=1):
"""Static glyph markup. Satellite dot for diaspora."""
cells = []
for n in range(1, GRID_COLS * GRID_ROWS + 1):
if n not in CONT:
cells.append('')
elif n in MASKS[key]:
cells.append('')
else:
cells.append('')
sat = ''
wrap = ''
if key == "dia":
sat = ('')
wrap = 'margin-inline-end:6px;'
return (''
'%s%s'
% (wrap, size, round(size / GRID_COLS * GRID_ROWS), gap, ''.join(cells), sat))
def seal(kind="person", size=16):
"""kind: person (outline) | org (solid)."""
star = ('M8 4.4L9.15 7.42L12.37 7.58L9.85 9.6L10.7 12.72L8 10.95'
'L5.3 12.72L6.15 9.6L3.63 7.58L6.85 7.42Z')
s = (''
def icon(name, size=20, stroke=1.5):
p = {
"search":'',
"gear":'',
"back":'',
"close":'',
"home":'',
"compass":'',
"bell":'',
"mail":'',
"user":'',
"plus":'',
"image":'',
"mic":'',
"flag":'',
"reply":'',
"repost":'',
"heart":'',
"send":'',
"chev":'',
"camera":'',
"shield":'',
"eye":'',
}[name]
vb = 20 if name not in ("home","plus") else 24
if name == "home": vb = 22
return (''
% (size, size, vb, vb, stroke, p))
# ---------- shell pieces ----------
def appbar(title=None, back=False, right="search+bell+gear", nav=False):
bh = ' onClick="{{navBack}}"' if nav else ''
gh = ' onClick="{{navSignup}}"' if nav else ''
left = ('%s' % (bh, icon("back"))) if back else ''
if title:
mid = ('%s' % title)
else:
mid = 'AfriX'
r = ''
if "search" in right: r += '%s' % icon("search")
if "bell" in right:
r += ('%s7' % icon("bell"))
if "gear" in right: r += '%s' % (gh, icon("gear"))
return '
%s%s%s
' % (left, mid, r)
def rail(active="all", dim=False, scroll=0):
items = []
for i, (k, label) in enumerate(SCOPES):
sel = '1' if k == active else '0'
st = ''
if scroll and i == 0:
st = ' style="margin-inline-start:-%dpx"' % scroll
items.append('' % (sel, st, glyph(k), label))
return '
"""
BASE_PROPS = '{"theme":{"editor":"enum","options":["dark","light"],"default":"light","section":"Theme"},"$preview":{"width":390,"height":844}}'
BASE_LOGIC = """ renderVals() {
return { theme: this.props.theme ?? 'light' };
}"""
def write(name, body, logic=BASE_LOGIC, props=BASE_PROPS, extra_css="", rootattr=""):
src = TPL % dict(fonts=FONTS, css=CSS, extra_css=extra_css, body=body,
props=props, logic=logic, rootattr=rootattr)
with open(os.path.join(OUT, name + ".dc.html"), "w") as f:
f.write(src)
return name
# ---------- feed data (shared by the live Home feed) ----------
FEED = {
"all": [
dict(i="SM",n="Sahel Monitor",s="org",h="@sahelmonitor",t="09:12",k="text",
x="The revised cross-border tariff schedule takes effect Monday. We have published the full gazette text and a plain-language summary of what changes at the six busiest land crossings.",
f="\U0001F1F2\U0001F1F1",c="MLI",ct=("412","1.2k","3.4k")),
dict(i="AY",n="Amina Yusuf",s="person",h="@aminay",t="08:47",k="voice",dur="0:47",lang="SW",
f="\U0001F1F0\U0001F1EA",c="KEN",ct=("34","12","210")),
dict(i="KM",n="Kwame Mensah",s="",h="@kwamem",t="08:20",k="image",
x="#TemaPort at 06:40. The queue is shorter than last week, but the bottleneck is the paperwork desk, not the cranes. Clearing times posted below.",
tone="#2E3D57",bytes="24 KB",f="\U0001F1EC\U0001F1ED",c="GHA",ct=("88","26","540")),
],
"north": [
dict(i="YB",n="Youssef Benali",s="person",h="@ybenali",t="09:26",k="text",
x="The tram extension opens this month: nine new stops, and the first line that actually reaches the industrial zone. The Line 3 timetable is in the replies.",
f="\U0001F1F2\U0001F1E6",c="MAR",ct=("74","188","812")),
dict(i="MD",n="Maghreb Data Office",s="org",h="@maghrebdata",t="08:58",k="text",
x="Regional inflation figures for July, country by country, methodology note attached rather than linked. #MaghrebData",
f="\U0001F1F9\U0001F1F3",c="TUN",ct=("96","240","611")),
dict(i="NC",n="Nadia Cherif",s="",h="@nadiac",t="08:31",k="voice",dur="0:33",lang="AR",
f="\U0001F1E9\U0001F1FF",c="DZA",ct=("22","7","140")),
],
"west": [
dict(i="LB",n="Lagos Business Review",s="org",h="@lagosbr",t="09:31",k="text",
x="Clearing times at the two largest commercial banks fell sharply this week, and the Abidjan backlog with them. We asked both of them what changed.",
f="\U0001F1F3\U0001F1EC",c="NGA",ct=("210","640","1.9k")),
dict(i="FD",n="Fatou Diallo",s="person",h="@fatoud",t="09:05",k="voice",dur="1:12",lang="FR",
f="\U0001F1F8\U0001F1F3",c="SEN",ct=("41","9","188")),
dict(i="IA",n="Ifeoma Adeyemi",s="",h="@ifeomaa",t="08:44",k="text",
x="Three years of Volta rainfall readings, cleaned and published as open CSV. Take it, check it, tell me what I got wrong. #VoltaData",
f="\U0001F1EC\U0001F1ED",c="GHA",ct=("62","154","703")),
],
"east": [
dict(i="ER",n="East African Rail Watch",s="org",h="@earailwatch",t="09:18",k="text",
x="Freight volumes on the inland rail corridor rose again last month. Chart below, the raw series in the replies, and the fertiliser portal figures beside them.",
f="\U0001F1F0\U0001F1EA",c="KEN",ct=("130","415","1.1k")),
dict(i="AY",n="Amina Yusuf",s="person",h="@aminay",t="08:47",k="voice",dur="0:47",lang="SW",
f="\U0001F1F0\U0001F1EA",c="KEN",ct=("34","12","210")),
dict(i="ZH",n="Zeinab Hassan",s="",h="@zeinabh",t="08:12",k="text",
x="The new bus lanes have cut my commute by twenty minutes. Posting before and after times every day for a month. #BusLanes",
f="\U0001F1F9\U0001F1FF",c="TZA",ct=("58","96","430")),
],
"south": [
dict(i="TN",n="Thandiwe Nkosi",s="person",h="@thandiwen",t="09:22",k="text",
x="The municipal budget hearings are open to the public all this week and almost nobody knows. Times and venues in the replies.",
f="\U0001F1FF\U0001F1E6",c="ZAF",ct=("145","380","1.4k")),
dict(i="SG",n="Southern Grid Monitor",s="org",h="@sgridmonitor",t="08:55",k="text",
x="This week's load-shedding schedule as published, set against the outage times we actually measured last week.",
f="\U0001F1FF\U0001F1E6",c="ZAF",ct=("88","310","905")),
dict(i="TM",n="Tapiwa Moyo",s="",h="@tapiwam",t="08:03",k="image",
x="The book fair moved to the gardens this year. Far better use of the space, and twice the people.",
tone="#3A4A63",bytes="31 KB",f="\U0001F1FF\U0001F1FC",c="ZWE",ct=("30","45","260")),
],
"dia": [
dict(i="CO",n="Chidi Okonkwo",s="person",h="@chidio",t="09:44",k="text",
x="Voting from abroad, two years of trying, and a short thread on exactly where the process breaks. It is not the part everyone assumes. #HomeElections",
f="\U0001F1F3\U0001F1EC",c="NGA",f2="\U0001F1EC\U0001F1E7",c2="GBR",ct=("320","890","2.6k")),
dict(i="AB",n="Aïssatou Bâ",s="",h="@aissatoub",t="09:10",k="voice",dur="0:58",lang="FR",
f="\U0001F1F8\U0001F1F3",c="SEN",f2="\U0001F1EB\U0001F1F7",c2="FRA",ct=("47","18","265")),
dict(i="RD",n="Remittance Desk",s="org",h="@remitdesk",t="08:36",k="text",
x="What it costs to send one hundred units home, by corridor, updated monthly. Six got cheaper, two got worse. #RemittanceCosts, and the same table for shipping home.",
f="\U0001F1EC\U0001F1ED",c="GHA",f2="\U0001F1FA\U0001F1F8",c2="USA",ct=("112","298","770")),
],
}
FEED_EXTRA = {
"all": [
dict(i="YB",n="Youssef Benali",s="person",h="@ybenali",t="07:52",k="text",
x="Spent the morning at the new interchange. The signage is in two languages only, which is going to be a problem for half the people using it.",
f="\U0001F1F2\U0001F1E6",c="MAR",ct=("64","121","489")),
dict(i="ZH",n="Zeinab Hassan",s="",h="@zeinabh",t="07:30",k="voice",dur="1:04",lang="SW",
f="\U0001F1F9\U0001F1FF",c="TZA",ct=("29","14","176")),
dict(i="CW",n="Continental Weather Desk",s="org",h="@weatherdesk",t="07:05",k="text",
x="Harmattan haze is expected to thicken across the Gulf of Guinea from Thursday. Road and air visibility guidance in the thread, updated twice daily.",
f="\U0001F1F3\U0001F1EC",c="NGA",ct=("188","702","1.5k")),
],
"north": [
dict(i="LM",n="Leila Mansouri",s="",h="@leilam",t="08:10",k="text",
x="Every ministry publishes its budget as a scanned PDF. Scanned. You cannot search it, you cannot total it, and that is not an accident.",
f="\U0001F1EA\U0001F1EC",c="EGY",ct=("240","880","2.1k")),
dict(i="CT",n="Cairo Transit Watch",s="org",h="@cairotransit",t="07:44",k="text",
x="Line 3 ran to timetable on nineteen of the last twenty weekdays. We publish the daily readings whether they flatter the operator or not.",
f="\U0001F1EA\U0001F1EC",c="EGY",ct=("77","195","540")),
dict(i="OH",n="Omar Haddad",s="",h="@omarh",t="07:20",k="image",
x="The old customs house, still empty. Someone has repainted the door.",
tone="#3A4A63",bytes="22 KB",f="\U0001F1F1\U0001F1FE",c="LBY",ct=("18","6","132")),
],
"west": [
dict(i="AB",n="Aisha Bello",s="",h="@aishab",t="08:20",k="image",
x="Market at first light. The tomato price board has not been updated in three weeks and everyone is negotiating blind.",
tone="#2E3D57",bytes="27 KB",f="\U0001F1F3\U0001F1EC",c="NGA",ct=("94","210","760")),
dict(i="WP",n="West Africa Ports Desk",s="org",h="@wapdesk",t="08:02",k="text",
x="Abidjan cleared its backlog on Tuesday. Tema has not. The difference is one shift pattern, and we have asked both authorities to explain it.",
f="\U0001F1E8\U0001F1EE",c="CIV",ct=("115","330","880")),
dict(i="KM",n="Kwame Mensah",s="",h="@kwamem",t="07:41",k="voice",dur="0:52",lang="EN",
f="\U0001F1EC\U0001F1ED",c="GHA",ct=("36","19","244")),
],
"east": [
dict(i="RV",n="Rift Valley Farmers Union",s="org",h="@rvfarmers",t="07:58",k="text",
x="The fertiliser subsidy portal has been down for nine days. We have logged every outage since March and will publish the log on Friday.",
f="\U0001F1F0\U0001F1EA",c="KEN",ct=("162","540","1.3k")),
dict(i="DW",n="Daniel Wekesa",s="",h="@danielw",t="07:36",k="voice",dur="0:41",lang="SW",
f="\U0001F1FA\U0001F1EC",c="UGA",ct=("27","11","165")),
dict(i="ST",n="Selam Tesfaye",s="person",h="@selamt",t="07:12",k="text",
x="Translating the new labour code into Amharic and Oromo, clause by clause, in public. Corrections welcome and credited.",
f="\U0001F1EA\U0001F1F9",c="ETH",ct=("204","615","1.8k")),
],
"south": [
dict(i="LD",n="Lerato Dube",s="",h="@leratod",t="07:49",k="text",
x="Two hours at the licensing office to be told the form changed in April. The new form is not online anywhere. I have scanned it.",
f="\U0001F1E7\U0001F1FC",c="BWA",ct=("86","260","920")),
dict(i="ND",n="Namib Data Desk",s="org",h="@namibdata",t="07:25",k="text",
x="Borehole readings for the central basin, monthly since 2019, now published as open CSV with the gaps marked as gaps.",
f="\U0001F1F3\U0001F1E6",c="NAM",ct=("54","178","466")),
dict(i="SN",n="Sipho Ndlovu",s="",h="@siphon",t="07:02",k="voice",dur="1:20",lang="EN",
f="\U0001F1FF\U0001F1E6",c="ZAF",ct=("48","22","318")),
],
"dia": [
dict(i="NC",n="Nadia Cherif",s="",h="@nadiac",t="08:14",k="text",
x="Renewed my passport from here in eleven weeks. My brother, same consulate, same paperwork, waited seven months. Nobody can tell us why.",
f="\U0001F1E9\U0001F1FF",c="DZA",f2="\U0001F1EB\U0001F1F7",c2="FRA",ct=("178","520","1.6k")),
dict(i="KB",n="Kofi Boateng",s="",h="@kofib",t="07:50",k="voice",dur="0:44",lang="EN",
f="\U0001F1EC\U0001F1ED",c="GHA",f2="\U0001F1E8\U0001F1E6",c2="CAN",ct=("33","15","198")),
dict(i="DL",n="Diaspora Legal Aid",s="org",h="@dialegal",t="07:22",k="text",
x="Free clinic on inheritance and land title across borders, Saturday, and again next month. Bring documents, bring questions, bring nothing at all.",
f="\U0001F1F0\U0001F1EA",c="KEN",f2="\U0001F1E6\U0001F1EA",c2="ARE",ct=("140","410","1.1k")),
],
}
for _k, _v in FEED_EXTRA.items():
FEED[_k].extend(_v)
def js_feed():
out = {}
for k, posts in FEED.items():
arr = []
for idx, p in enumerate(posts):
arr.append(dict(
id="%s-%d" % (k, idx), initials=p["i"], name=p["n"], handle=p["h"], ts=p["t"],
isPerson=(p["s"] == "person"), isOrg=(p["s"] == "org"),
hasText=("x" in p), text=p.get("x", ""),
isVoice=(p["k"] == "voice"), dur=p.get("dur", ""), lang=p.get("lang", ""),
isImage=(p["k"] == "image"), tone=p.get("tone", ""), bytes=p.get("bytes", ""),
flag=p["f"], code=p["c"], flag2=p.get("f2", ""), code2=p.get("c2", ""),
isDia=("f2" in p), notDia=("f2" not in p),
c1=p["ct"][0], c2=p["ct"][1], c3=p["ct"][2], **av_fields(p["i"])))
out[k] = arr
return json.dumps(out, ensure_ascii=False)
def js_scopes():
out = []
for k, label in SCOPES:
cells = []
for n in range(1, GRID_COLS * GRID_ROWS + 1):
if n not in CONT: cells.append({"st": "background:transparent"})
elif n in MASKS[k]: cells.append({"st": "background:currentColor"})
else: cells.append({"st": "background:currentColor;opacity:.28"})
out.append({"key": k, "label": label, "cells": cells, "isDia": k == "dia"})
return json.dumps(out, ensure_ascii=False)
SEAL_P = seal("person")
SEAL_O = seal("org")
def storytray(seen_from=3, nav=False):
people = [("AY","Amina",0),("KM","Kwame",0),("TN","Thandiwe",0),("YB","Youssef",1),("FD","Fatou",1)]
ph = ' onClick="{{navStories}}"' if nav else ''
vh = ' onClick="{{navViewer}}"' if nav else ''
cells = ['
'
'
%s
'
'You
' % (ph, icon("plus", 18, 1.5))]
for ini, nm, seen in people:
ring = ('border:1px solid var(--hair);padding:3px' if seen else 'border:2px solid var(--accent);padding:2px')
cells.append('
'
'
%s
'
'%s
'
% (vh, ring, av(ini, 50, "width:100%;height:100%;"), 'var(--ink3)' if seen else 'var(--ink2)', nm))
return ('
%s
' % ''.join(cells))
def prompt_row(nav=False):
"""The head of the feed. Opens the composer IN PLACE — the reader never leaves
the feed to write a post (spec SCRN-1)."""
ch = ' onClick="{{navInline}}"' if nav else ''
return ('' % (ch, av("AY")))
MAIN_BODY = """
%(appbar)s
%(fab)s
%(tabs)s
"""
MAIN_LOGIC = """ componentDidMount() {
this.setState({ scope: 'all', playingId: null, prog: 0 });
}
componentWillUnmount() { if (this._t) clearInterval(this._t); }
toggle(id) {
var st = this.state || {};
if (st.playingId === id) {
if (this._t) { clearInterval(this._t); this._t = null; }
this.setState({ playingId: null, prog: 0 });
return;
}
if (this._t) { clearInterval(this._t); this._t = null; }
this.setState({ playingId: id, prog: 0 });
var self = this;
this._t = setInterval(function () {
var s = self.state || {};
var p = (s.prog || 0) + 0.018;
if (p >= 1) { clearInterval(self._t); self._t = null; self.setState({ playingId: null, prog: 0 }); }
else { self.setState({ prog: p }); }
}, 90);
}
renderVals() {
var FEED = %(feed)s;
var SCOPES = %(scopes)s;
var PEAKS = %(peaks)s;
var self = this;
var st = this.state || {};
var scope = st.scope || 'all';
var scopes = SCOPES.map(function (s) {
return { key: s.key, label: s.label, cells: s.cells, isDia: s.isDia,
sel: s.key === scope ? '1' : '0',
wrapStyle: s.isDia ? 'margin-inline-end:6px' : '',
pick: function () { self.setState({ scope: s.key, playingId: null, prog: 0 }); } };
});
var posts = (FEED[scope] || []).map(function (p) {
var playing = st.playingId === p.id;
var cut = playing ? Math.floor(PEAKS.length * (st.prog || 0)) : 0;
var bars = PEAKS.map(function (v, i) {
return { st: 'height:' + Math.max(3, Math.round(v * 0.26)) + 'px;background:' +
(i < cut ? 'var(--accent)' : 'var(--ink3)') };
});
return Object.assign({}, p, { bars: bars,
toneStyle: 'height:150px;background:' + (p.tone || '#2E3D57'),
open: function () {}, openProfile: function () {},
toggle: function () { self.toggle(p.id); } });
});
return { theme: this.props.theme ?? 'light', scopes: scopes, posts: posts };
}"""
write("Main",
MAIN_BODY % dict(avtpl=av_template("p", ' onClick="{{p.openProfile}}"'),
appbar=appbar(), tray=storytray(), fab=fab(), tabs=tabbar("home"),
prompt=prompt_row(),
sealp=SEAL_P, sealo=SEAL_O,
icrep=icon("reply", 15, 1.4), icrp=icon("repost", 15, 1.4), ichr=icon("heart", 15, 1.4)),
logic=MAIN_LOGIC % dict(feed=js_feed(), scopes=js_scopes(), peaks=json.dumps(PEAKS)))
print("Main ok")
# ---------- static card renderer ----------
def scard(p, dim=False, prog=0.0, tone_h=150, loaded_svg=None, av=40):
s = SEAL_P if p.get("s") == "person" else (SEAL_O if p.get("s") == "org" else "")
body = ""
if "x" in p:
col = ';color:var(--ink2)' if dim else ''
body += '
%s
' % (col, p["x"])
if p["k"] == "voice":
body += voice(p["dur"], p.get("lang", ""), prog)
if p["k"] == "image":
if loaded_svg:
body += ('
%s
'
% (tone_h, p.get("tone", "#2E3D57"), loaded_svg))
else:
body += tone(tone_h, p.get("tone", "#2E3D57"),
chip="Photo · %s — Tap to load" % p.get("bytes", "24 KB"))
if p.get("f2"):
geo = ('%s %s'
'→'
'%s %s' % (p["f2"], p["c2"], p["f"], p["c"]))
else:
geo = geochip(p["f"], p["c"])
if p["k"] == "voice":
geo += '%s' % p.get("lang", "")
ts = p["t"]
if dim:
ts = 'QUEUED'
return card(p["i"], p["n"], p.get("s") or "", p["h"], ts, body,
pfoot(geo, p["ct"]), dim=dim)
def scene(kind="port"):
"""Flat vector stand-in for a loaded photo — deliberately not a fake photograph."""
if kind == "port":
return ('')
return ('')
F = FEED # shorthand
# ---------- 2. Post detail ----------
p0 = F["all"][0]
replies = [
dict(i="AY",n="Amina Yusuf",s="person",h="@aminay",t="09:31",
x="The plain-language summary is the useful part. Most traders at the crossing will never read a gazette.", ct=("6","1","44")),
dict(i="IA",n="Ifeoma Adeyemi",s="",h="@ifeomaa",t="09:44",
x="Does the schedule cover the seasonal exemptions, or is that a separate instrument?", ct=("2","0","18")),
dict(i="SM",n="Sahel Monitor",s="org",h="@sahelmonitor",t="09:52",
x="Separate instrument, published the same day. We have linked it at the foot of the summary.", ct=("1","0","31")),
dict(i="KM",n="Kwame Mensah",s="",h="@kwamem",t="10:06",
x="Printing the summary for the desks at the port. Fifty copies. Someone should be doing this at every crossing.", ct=("4","12","96")),
dict(i="ZH",n="Zeinab Hassan",s="",h="@zeinabh",t="10:18",
x="Is there a Kiswahili version, or is that something we should organise ourselves?", ct=("3","0","27")),
dict(i="FD",n="Fatou Diallo",s="person",h="@fatoud",t="10:24",
x="French version came out this morning. A translation of the summary, not of the gazette, which is the right call.", ct=("0","2","41")),
]
rep_html = ""
for r in replies:
rep_html += ('
Recorded this at the crossing this morning — the traders explain the new schedule far better than the gazette does.
%(wave)s0:47
OPUS 24 kbps · 148 KB
Text you add becomes this post’s transcript, so people who cannot play audio can still read it.
%(img)s%(mic)s%(flag)s MLIFR
"""
write("Compose", COMPOSE % dict(avYO=av("YO"), wave=wave(0.0), img=icon("image",20), mic=icon("mic",20),
flag="\U0001F1F2\U0001F1F1"))
print("2-3 ok")
# ---------- 3b. Compose: the take in progress (VOICE-4) ----------
# One level bar, not a scrolling waveform: the meter is a single width sampled
# every 100ms. The same samples become the 60 buckets the review state draws.
RECORDING = """
Cancel
%(avYO)s
What is happening?
0:41~123 KB
INPUT LEVEL · SAMPLED EVERY 100 MS
0:41 OF 3:00
Three minutes maximum. The size is stated as you go, so nothing about what you are
about to send is a surprise.
%(img)s%(mic)s%(flag)s MLIFR
"""
write("Recording", RECORDING % dict(avYO=av("YO"), img=icon("image",20), mic=icon("mic",20),
flag="\U0001F1F2\U0001F1F1"))
print("3b ok")
# ---------- 3c. Compose: the two ways recording is unavailable ----------
# VOICE-4 makes these separate screens on purpose. One is a dead end; the other is a
# setting the reader can change. Same box with a different verb would tell them nothing.
MICSTATE = """
Cancel
A · UNSUPPORTED
This browser cannot record audio. You can still write a post.
No record control is drawn at all — and nothing was asked for.
B · DENIED
AfriX needs permission to use your microphone. Allow it in your browser settings,
then try again.
Two states, two sentences. A is a dead end and the control should not be there to
press. B is a setting the reader can change, so it says where. Capability is read from
the browser before anything is requested, which is why A never raises a prompt.
Reporting on cross-border trade and the people who actually move the goods. Kiswahili and English. Voice notes most mornings.
%(flag)s KENJOINED MAR 2025
1,842 following64,209 followers
%(seal16)sIdentity verified by Federal Africa · 11 MAR 2026
Posts
Replies
Media
%(post)s
%(tabs)s
"""
write("Profile", PROFILE % dict(avAY88=av("AY", 88, "border:3px solid var(--bg);"),
appbar=appbar(title="Amina Yusuf", back=True, right="gear"), mail=icon("mail",18),
seal20=seal("person",20), seal16=seal("person",16), flag="\U0001F1F0\U0001F1EA",
post=scard(F["all"][1]) + scard(F["all"][4]) + scard(F["east"][2]) + scard(F["all"][3]), tabs=tabbar("home")))
# ---------- 5. Explore ----------
RAIL_SCROLL = 170
TRENDS = [
("01","Voting from abroad","6,120 posts","Consular registration closes Friday"),
("02","#RemittanceCosts","3,410 posts","New corridor figures published"),
("03","Dual nationality","2,180 posts",""),
("04","#HomeElections","1,760 posts","Six countries voting this quarter"),
("05","Shipping to Lagos","940 posts",""),
("06","#ConsularQueues","780 posts","Three consulates named in one thread"),
("07","Land title abroad","540 posts",""),
("08","#SendingItHome","410 posts",""),
]
trows = ""
for r, t, c, why in TRENDS:
why_html = ('
%s
' % why) if why else ''
trows += ('
'
'%s'
'
%s
'
'
%s
%s
'
% (r, t, c, why_html))
EXPLORE = """
%(search)sSearch AfriX
%(rail)s
TRENDING INDiaspora
%(trows)s
ACCOUNTS TO FOLLOW
%(avLB)s
Lagos Business Review%(sealo)s
@lagosbr
%(avRD)s
Remittance Desk%(sealo2)s
@remitdesk
%(avCO)s
Chidi Okonkwo%(sealp2)s
@chidio
%(tabs)s
"""
write("Explore", EXPLORE % dict(avLB=av("LB"), avRD=av("RD"), avCO=av("CO"),search=icon("search",18), rail=rail("dia", scroll=RAIL_SCROLL), trows=trows,
sealo=SEAL_O, sealo2=SEAL_O, sealp2=SEAL_P, tabs=tabbar("explore")))
print("4-5 ok")
# ---------- 6. Messages (conversation list) ----------
CONVOS = [
("AY","Amina Yusuf","person","Sent the recording — the second half is the useful bit.","09:41",""),
("KM","Kwame Mensah","","I will be at the port desk until noon if you want the queue numbers.","08:58",""),
("SM","Sahel Monitor","org","Thanks — we have credited you on the summary page.","Yesterday",""),
("TN","Thandiwe Nkosi","person","The hearing schedule changed again. Sending the new one.","Yesterday","3"),
("IA","Ifeoma Adeyemi","","The CSV is up. Column names are a mess, sorry.","Mon",""),
("YB","Youssef Benali","person","Sending the interchange photos tonight once I am on wifi.","Mon",""),
("FD","Fatou Diallo","person","Merci — I have credited you in the French summary.","Sun",""),
("LB","Lagos Business Review","org","Would you go on record about the clearing times?","Sun",""),
("CO","Chidi Okonkwo","person","The consular thread got picked up. Brace yourself.","Sat","1"),
("ZH","Zeinab Hassan","","Bus lane numbers for week three, attached.","Fri",""),
]
crows = ""
for ini, nm, sk, prev, ts, unread in CONVOS:
s = SEAL_P if sk == "person" else (SEAL_O if sk == "org" else "")
badge = ('%s'
% unread) if unread else ''
crows += ('
'
% (text, ts or '', ('%s' % tag) if tag else ''))
return ('
'
'
%s
'
'
%s
'
% (text, ts or ''))
THREAD = """
%(back)s
%(avAY32)s
Amina Yusuf%(seal)s
%(chev)s
TODAY
%(m1)s
%(m2)s
%(m3)s
%(m4)s
%(m5)s
%(img)s
Message
%(mic)s
"""
TM1 = bubble("Are you going to the crossing before the schedule takes effect?", ts="08:44")
TM2 = bubble("Tomorrow, first thing. I want the traders on record before the desks change over.", own=True, tag="SENT", ts="08:51")
TM3 = bubble("Good. The gazette text is unreadable for anyone who is not a customs broker.", ts="08:53")
TM4 = bubble("Sent the recording — the second half is the useful bit.", ts="09:41")
TM5 = bubble("Listening now. Can I quote the part about the paperwork desk?", own=True, tag="QUEUED", ts="09:47")
TM0a = bubble("Did the gazette actually land, or is it still ‘imminent’?", ts="08:31")
TM0b = bubble("It landed. 04:00, no announcement, no summary.", own=True, tag="SENT", ts="08:38")
TM0c = bubble("Of course it did.", ts="08:40")
TM6 = bubble("Quote whatever you need. Credit the union rep, not me — she said the useful part.", ts="09:52")
TM7 = bubble("Understood. Sending you the draft before it goes up.", own=True, tag="SENDING", ts="09:55")
write("Thread", THREAD % dict(avAY32=av("AY", 32),
back=icon("back"), seal=SEAL_P, chev=icon("chev",18), img=icon("image",20), mic=icon("mic",20),
m1=TM0a+TM0b+TM0c+TM1, m2=TM2, m3=TM3, m4=TM4, m5=TM5+TM6+TM7))
print("6-7 ok")
# ---------- 8. Stories (tray) ----------
SLIST = [
("AY","Amina Yusuf","person","14 min ago",0),
("KM","Kwame Mensah","","38 min ago",0),
("TN","Thandiwe Nkosi","person","1 h ago",0),
("YB","Youssef Benali","person","4 h ago",1),
("FD","Fatou Diallo","","6 h ago",1),
("ZH","Zeinab Hassan","","8 h ago",1),
("SM","Sahel Monitor","org","9 h ago",1),
("IA","Ifeoma Adeyemi","","11 h ago",1),
("CO","Chidi Okonkwo","person","14 h ago",1),
]
srows = ""
for ini, nm, sk, ago, seen in SLIST:
s = SEAL_P if sk == "person" else ""
ring = ('border:1px solid var(--hair);padding:3px' if seen else 'border:2px solid var(--accent);padding:2px')
lbl = ('SEEN') if seen else ''
srows += ('
%(tabs)s
"""
write("Stories", STORIES % dict(avYO46=av("YO", 46),appbar=appbar(title="Stories", back=True, right=""), tray=storytray(),
eye=icon("eye",15,1.4), rows=srows, tabs=tabbar("home")))
# ---------- 9. Story viewer ----------
segs = ""
for i in range(5):
fill = "var(--onfill)" if i < 1 else ("var(--onfill)" if i == 1 else "rgba(11,18,32,.35)")
w = "100%" if i < 1 else ("46%" if i == 1 else "100%")
bg = "rgba(11,18,32,.3)"
inner = '' % (w if i <= 1 else "0%")
segs += '
%s
' % (bg, inner)
VIEWER = """
%(segs)s
%(avAY34)s
Amina Yusuf
14 min%(close)s
The paperwork desk opens at 07:00. The cranes have been running since five.
This puts a verified seal on your profile. It means a real, identity-checked person or organisation — not a subscription.
WHAT AFRIX RECEIVES
%(g1)s%(g2)s%(g3)s
WHAT IT NEVER RECEIVES
%(n1)s%(n2)s%(n3)s
You can disconnect it later. If the record is withdrawn, the seal goes with it.
"""
# ---------- 14b. Sign in ----------
# The pair to SignUp. Auth sits outside the shell — no rail, no tab bar — but it
# carries the wordmark and the language chips: a reader arriving on a shared link
# needs to know what they are signing in to, and this is the first screen a new
# reader sees and the only one where the language could not be changed (AUTH-1).
SIGNIN = """
AfriXENFRSWAR
SIGN IN
Welcome back
You do not need an account to read AfriX. Sign in to post, reply, follow and message.
Email
Password
or continue with
Phone SOON
New here? Create an account
· Keep reading without one
"""
write("SignIn", SIGNIN)
print("14b ok")
SIGNUP_SRC = SIGNUP % dict(back=icon("back"),
g1=bullet("Your legal name, as it appears on the record"),
g2=bullet("Your country and account class — person, organisation or public body"),
g3=bullet("The date verification was issued"),
n1=bullet("Your national ID number", "no"),
n2=bullet("Your address, date of birth or contact details", "no"),
n3=bullet("Anything you post, read or search on AfriX", "no"))
write("SignUp", SIGNUP_SRC)
# ---------- 11. Skeleton state ----------
def sk(w, h=12, mt=0, r=0):
return ('' % (w, h, mt, "50%%" if r else "0"))
sk_cards = ""
for w1, w2, w3 in [("62%","96%","74%"), ("48%","88%","62%"), ("70%","92%","58%"),
("55%","94%","0"), ("66%","90%","71%"), ("52%","95%","44%")]:
third = ('' % w3) if w3 != "0" else ''
sk_cards += ('
'
''
'
'
''
''
''
'%s'
'
'
''
''
''
'
' % (w1, w2, third))
sk_tray = ('
' +
''.join('
'
''
'
' for _ in range(5)) +
'
')
SKELETON = """
%(appbar)s
%(rail)s
%(tray)s
%(cards)s
%(fab)s
%(tabs)s
"""
write("Skeleton", SKELETON % dict(appbar=appbar(), rail=rail("all"), tray=sk_tray,
cards=sk_cards, fab=fab(), tabs=tabbar("home")))
# ---------- 12. Offline / queued ----------
queued = dict(i="YO",n="You",s="",h="@yourhandle",t="",k="text",
x="At the Kouremale crossing. The traders were told about the new schedule this morning, same as everyone else.",
f="\U0001F1F2\U0001F1F1",c="MLI",ct=("—","—","—"))
OFFLINE = """
%(appbar)s
Offline — showing posts saved on this phone
3 posts and 2 likes waiting to send
%(rail)s
%(qcard)s
%(c1)s
%(c2)s
%(tabs)s
"""
write("Offline", OFFLINE % dict(appbar=appbar(), rail=rail("all", scroll=90), qcard=scard(queued, dim=True),
c1=scard(F["all"][0]), c2=scard(F["all"][1]) + scard(F["all"][3]) + scard(F["all"][5]),
tabs=tabbar("home")))
print("10-12 ok")
# ---------- 12b. Notifications (NOTIF) ----------
# Two classes, and the separation is the whole section: enforcement and report
# updates are ruled in the accent and cannot be switched off; likes and follows
# are in-app only and never reach a device.
NOTIFS = [
("case", "Action was taken on your post", "A4C-2291", "09:52", None),
("case", "Your report has an update", "K7M-4410", "09:48", None),
("soc", "Amina Yusuf", "replied to your post", "09:44", ["AY"]),
("soc", "Kwame Mensah", "and 12 others liked your post", "09:31", ["KM", "CO"]),
("soc", "Sahel Monitor", "and 3 others reposted you", "09:10", ["SM"]),
("soc", "Ifeoma Adeyemi", "and 5 others followed you", "08:40", ["IA"]),
("soc", "Youssef Benali", "and 2 others mentioned you", "08:04", ["YB"]),
]
nrows = ""
for kind, a, b, ts, who in NOTIFS:
if kind == "case":
nrows += ('
'
'
%s
'
'
%s
'
'%s
' % (a, b, ts))
else:
avs = "".join(av(k, 30, "margin-inline-start:-10px;" if i else "") for i, k in enumerate(who))
nrows += ('
'
'%s'
'
%s %s
'
'%s
' % (avs, a, b, ts))
NOTIF_SRC = """
%(appbar)s
%(rows)s
Enforcement and report updates always reach you, and cannot be turned off.
Likes and follows are in-app only — they never become a push.
%(tabs)s
"""
write("Notifications", NOTIF_SRC % dict(appbar=appbar(title="Notifications", back=True, right="gear"),
rows=nrows, tabs=tabbar("home")))
# ---------- 12c. Settings (SET-1) ----------
SETGROUPS = [
("Profile", ["Display name", "Bio", "Country", "Avatar and banner", "Handle — @aminay"]),
("Appearance", ["Theme — follows the system", "Text size — default"]),
("Language", ["Interface — English", "Post language — English",
"Translate posts — never automatically"]),
("Data", ["Load photos on Wi-Fi — off", "Cached posts — 12 MB", "Clear cache",
"Download your data"]),
("Safety", ["Blocked accounts — 4", "Muted words — 12",
"Who can message you — people you follow"]),
("Sign-in", ["Sessions — 2 devices", "Federal Africa link — connected", "Sign out"]),
("Your reports", ["K7M-4410 — under review", "A4C-2291 — upheld",
"D2P-8834 — declined"]),
]
stabs = "".join('%s'
% ("color:var(--ink);font-weight:600;border-bottom:2px solid var(--accent)"
if g == "Data" else "color:var(--ink3)", g)
for g, _ in SETGROUPS)
srows_set = "".join('
%s
' % r
for r in dict(SETGROUPS)["Data"])
SET_SRC = """
%(appbar)s
%(tabs_g)s
Data
%(rows)s
Data is the first group anyone on a metered connection opens, so it says what is
stored and what it costs before it offers to change anything.
%(tabs)s
"""
DS_LOGIC = """ componentDidMount() { this.setState({ loaded: {} }); }
// portrait attributes, computed at build time so both clients draw identical art
renderVals() {
var self = this;
var AV_FIELDS = %(avf)s;
var loaded = (this.state || {}).loaded || {};
var DATA = [
{ id: 'a', initials: 'KM', name: 'Kwame Mensah', isPerson: false, handle: '@kwamem', ts: '08:20',
text: 'Tema port at 06:40. The bottleneck is the paperwork desk, not the cranes.',
tone: '#2E3D57', bytes: '24 KB', art: 'port', h: 96, flag: '\\uD83C\\uDDEC\\uD83C\\uDDED', code: 'GHA',
c1: '88', c2: '26', c3: '540', state: 'chip' },
{ id: 'b', initials: 'TM', name: 'Tapiwa Moyo', isPerson: false, handle: '@tapiwam', ts: '08:03',
text: 'The book fair moved to the gardens this year. Twice the people.',
tone: '#3A4A63', bytes: '31 KB', art: 'sky', h: 96, flag: '\\uD83C\\uDDFF\\uD83C\\uDDFC', code: 'ZWE',
c1: '30', c2: '45', c3: '260', state: 'loading' },
{ id: 'c', initials: 'AY', name: 'Amina Yusuf', isPerson: true, handle: '@aminay', ts: '07:48',
text: 'First light over the crossing. The trucks have queued since four.',
tone: '#2A3A55', bytes: '19 KB', art: 'sky', h: 96, flag: '\\uD83C\\uDDF0\\uD83C\\uDDEA', code: 'KEN',
c1: '52', c2: '31', c3: '410', state: 'loaded' },
{ id: 'd', initials: 'IA', name: 'Ifeoma Adeyemi', isPerson: false, handle: '@ifeomaa', ts: '07:31',
text: 'The gauge station at the river mouth, still standing.',
tone: '#33415C', bytes: '18 KB', art: 'port', h: 138,
flag: String.fromCodePoint(0x1F1EC, 0x1F1ED), code: 'GHA',
c1: '17', c2: '9', c3: '121', state: 'chip' }
];
var items = DATA.map(function (p) {
var isOn = p.state === 'loaded' || loaded[p.id];
var isLoading = p.state === 'loading' && !loaded[p.id];
return Object.assign({}, p, AV_FIELDS[p.id], {
showChip: !isOn && !isLoading,
isLoading: isLoading,
showPort: isOn && p.art === 'port',
showSky: isOn && p.art === 'sky',
toneStyle: 'height:' + (p.h || 96) + 'px;background:' + p.tone + ';',
load: function () {
var next = Object.assign({}, (self.state || {}).loaded || {});
next[p.id] = true;
self.setState({ loaded: next });
}
});
});
return { theme: this.props.theme ?? 'light', items: items };
}"""
write("DataSaver",
DS_BODY % dict(avtpl=av_template("p"), appbar=appbar(), rail=rail("all"), sealp=SEAL_P,
port=scene("port"), sky=scene("sky"), tabs=tabbar("home"),
icrep=icon("reply",15,1.4), icrp=icon("repost",15,1.4), ichr=icon("heart",15,1.4)),
logic=DS_LOGIC % dict(avf=json.dumps(
{"a": av_fields("KM"), "b": av_fields("IA"), "c": av_fields("TM"), "d": av_fields("AY")})))
print("13 ok")
# ---------- 14. Arabic RTL home feed ----------
AR_SCOPES = [("all","كل أفريقيا"),("north","شمال أفريقيا"),("west","غرب أفريقيا"),
("east","شرق أفريقيا"),("south","الجنوب الأفريقي"),("dia","المهجر")]
def ar_rail(active="north"):
items = []
for k, label in AR_SCOPES:
items.append(''
% ('1' if k == active else '0', glyph(k), label))
return '
%s
' % ''.join(items)
def ar_tabbar(active="home"):
items = [("home","التغذية","home"),("search","استكشاف","explore"),
("mail","رسائل","msg"),("user","أنت","you")]
out = []
for i, (ic, label, key) in enumerate(items):
out.append('
%s
'
% ('1' if key == active else '0', label, hicon(ic, 25)))
if i == 1:
out.append('
' % icon("plus", 18, 1.5)]
for ini, nm, seen in people:
ring = ('border:1px solid var(--hair);padding:3px' if seen else 'border:2px solid var(--accent);padding:2px')
cells.append('
'
'
'
'%s
'
'%s
'
% (ring, av(ini, 50, "width:100%;height:100%;"), 'var(--ink3)' if seen else 'var(--ink2)', nm))
return ('
%s
' % ''.join(cells))
def ar_card(ini, name, sk, handle, ts, text, flag, code, counts, voice_html=None):
s = SEAL_P if sk == "person" else (SEAL_O if sk == "org" else "")
body = ('
%s
' % text) if text else ''
if voice_html:
body += voice_html
geo = geochip(flag, code)
if voice_html:
geo += 'AR'
return ('
%(fab)s
%(tabs)s
"""
write("Arabic", ARABIC % dict(
search=icon("search",20), bell=icon("bell",20), gear=icon("gear",20), rail=ar_rail("north"), tray=ar_tray(),
c1=ar_card("م","مكتب بيانات المغرب الكبير","org","@maghrebdata","09:12",
"أرقام التضخم الإقليمية لشهر يوليو، بلدًا بلدًا، مع مذكّرة المنهجية مرفقة لا مرتبطة برابط خارجي.",
"\U0001F1F9\U0001F1F3","TUN",("96","240","611")),
c2=ar_card("ن","نادية شريف","","@nadiac","08:31","", "\U0001F1E9\U0001F1FF","DZA",("22","7","140"),
voice_html=voice("0:33","AR",0.0)),
c3=ar_card("ي","يوسف بنعلي","person","@ybenali","08:04",
"يُفتتح امتداد ترامواي الدار البيضاء أمام الجمهور يوم الرابع عشر. تسع محطات جديدة، وأوّل خط يصل فعلاً إلى المنطقة الصناعية.",
"\U0001F1F2\U0001F1E6","MAR",("74","188","812")),
fab=fab(), tabs=ar_tabbar("home")),
rootattr=' dir="rtl" lang="ar"',
extra_css='.afx[dir="rtl"]{font-family:"IBM Plex Sans Arabic","IBM Plex Sans",system-ui,sans-serif;font-size:17px}'
'.afx[dir="rtl"] .ptxt{font-size:16px;line-height:1.62}'
'.afx[dir="rtl"] .nm{font-size:15.5px}'
'.afx[dir="rtl"] .rail-i{font-size:13.5px}')
print("14 ok")
# ---------- 15. Prototype: every screen in one frame, wired together ----------
def scr(flag, body, hint="false"):
return (''
'