# -*- 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 ('' % (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 = ('' + 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 '
%s
' % ''.join(items) HERO = { "home":"M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25", "search":"m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z", "bell":"M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0", "mail":"M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75", "gear":"M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.03 7.03 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.02-.397-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 0 1 0-.255c.007-.378-.138-.75-.43-.991l-1.004-.828a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28ZM15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z", "user":"M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z", } def hicon(name, size=19): return ('' % (size, size, HERO[name])) def tabbar(active="home", labels=None, nav=False): items = [("home","Feed","home"),("search","Explore","explore"), ("mail","Messages","msg"),("user","You","you")] out = [] for i, (ic, label, key) in enumerate(items): sel = key == active if labels: label = labels[i] h = (' onClick="{{nav%s}}"' % key.capitalize()) if nav else '' out.append('
%s
' % ('1' if sel else '0', h, label, hicon(ic, 25))) if i == 1: ch = ' onClick="{{navCompose}}"' if nav else '' out.append('' % (ch, icon("plus", 26, 2))) return '
%s
' % ''.join(out) def fab(nav=False): return '' # ---------- avatars: illustrated portraits for people, monogram marks for institutions ---------- PHOTOS = sorted(os.path.basename(f) for f in glob.glob(os.path.join(OUT, "img", "p*.jpg"))) # Face framing per photograph — the crop happens in CSS, so the source stays uncropped. PHOTO_POS = {"p01.jpg": "50% 38%", "p02.jpg": "61% 40%", "p03.jpg": "50% 25%", "p04.jpg": "50% 27%", "p05.jpg": "50% 42%"} # Assigned rather than hashed, so a face is at least plausible for the name beside it. PHOTO_CAST = { "AY": "p02.jpg", "ZH": "p02.jpg", "NC": "p02.jpg", "LM": "p02.jpg", "TN": "p01.jpg", "FD": "p01.jpg", "IA": "p01.jpg", "AB": "p01.jpg", "ST": "p01.jpg", "LD": "p01.jpg", "KM": "p03.jpg", "DW": "p03.jpg", "SN": "p03.jpg", "CO": "p04.jpg", "TM": "p04.jpg", "KB": "p04.jpg", "YB": "p05.jpg", "OH": "p05.jpg", "YO": "p05.jpg", } def photo_for(key): if not PHOTOS: return None f = PHOTO_CAST.get(key) return f if f in PHOTOS else PHOTOS[_h(key) % len(PHOTOS)] def photo_style(f): return ('width:100%%;height:100%%;object-fit:cover;object-position:%s;display:block' % PHOTO_POS.get(f, "50% 40%")) ORGS = {"SM":"Sahel Monitor","LB":"Lagos Business Review","ER":"East African Rail Watch", "MD":"Maghreb Data Office","SG":"Southern Grid Monitor","RD":"Remittance Desk", "CW":"Continental Weather Desk","WP":"West Africa Ports Desk","RV":"Rift Valley Farmers Union", "CT":"Cairo Transit Watch","ND":"Namib Data Desk","DL":"Diaspora Legal Aid"} GROUND = ["#C7D3E3","#DCD0C0","#C5D6CB","#DBCFD7","#CDD7DE","#E1D8C6","#D2CBDD","#C9D9D6"] SKIN = ["#7A4A2B","#8C5A34","#5E3620","#A06B41","#6B4127","#93603A","#4E2C19","#B07E52"] HAIR = ["#241812","#1C1310","#2E1F17","#20160F"] WRAP = ["#8C3B2E","#2F5D50","#6A4E8C","#B5842E","#1E3A8A"] GARMENT= ["#2F4A6E","#6B3F3F","#3E5C4A","#55506E","#7A5C2E","#34495E","#4A3B62","#265A54"] ORGTONE= ["#1E3A8A","#0F5132","#7A2E2E","#3F4A5C","#1F4E5F","#5A3E86"] VARIANTS = ["crop","afro","wrap","locs","veil","fade"] SHAPE_BEHIND = { "afro": '', "locs": ('' '' ''), } SHAPE_FRONT = { "crop": (''), "fade": (''), "wrap": (''), "veil": (''), } def av_fields(key): """Portrait attributes shared by the static frames and the live feed.""" i = _h(key) var = VARIANTS[(i // 13) % 6] d = dict(avIsOrg=key in ORGS, avIsPerson=key not in ORGS, avCls=("av org" if key in ORGS else "av"), avGround=(ORGTONE[i % 6] if key in ORGS else GROUND[i % 8]), avSkin=SKIN[(i // 3) % 8], avHair=HAIR[(i // 5) % 4], avWrap=WRAP[(i // 7) % 5], avGarment=GARMENT[(i // 11) % 8]) for v in VARIANTS: d["av_" + v] = (v == var) ph = None if key in ORGS else photo_for(key) d["avHasPhoto"] = ph is not None d["avDrawn"] = (ph is None) and (key not in ORGS) for f in PHOTOS: d["ph_" + f.split(".")[0]] = (f == ph) return d def av_template(hole="p", click=""): def blocks(d): out = "" for v, frag in d.items(): body = frag % {"hair": "{{%s.avHair}}" % hole, "wrap": "{{%s.avWrap}}" % hole} out += '%s' % (hole, v, body) return out return ('
' '%s
' % (hole, click, hole, hole, hole, hole, hole, blocks(SHAPE_BEHIND), hole, blocks(SHAPE_FRONT), photo_blocks(hole))) def photo_blocks(hole): out = "" for f in PHOTOS: out += ('' '' % (hole, f.split(".")[0], f, photo_style(f))) return out def _h(key): v = 0 for ch in key: v = (v * 31 + ord(ch)) & 0xFFFF return v def portrait(key): i = _h(key) g, sk, ha, wr, ga = (GROUND[i % 8], SKIN[(i // 3) % 8], HAIR[(i // 5) % 4], WRAP[(i // 7) % 5], GARMENT[(i // 11) % 8]) var = VARIANTS[(i // 13) % 6] sub = {"hair": ha, "wrap": wr} behind = (SHAPE_BEHIND[var] % sub) if var in SHAPE_BEHIND else "" front = (SHAPE_FRONT[var] % sub) if var in SHAPE_FRONT else "" if False: behind = '' % ha elif var == "locs": behind = ('' '' '' % (ha, ha, ha)) if var == "crop": front = ('' % ha) elif var == "fade": front = ('' % ha) elif var == "wrap": front = ('' % (wr, wr)) elif var == "veil": front = ('' % wr) return ('').format( g=g, ga=ga, sk=sk, behind=behind, front=front) def orgmark(key): tone = ORGTONE[_h(key) % 6] return ('' % (tone, key)) def av(key, size=40, extra=""): """Institutions read as squares, people as circles — account class, visible at a glance.""" org = key in ORGS st = 'width:%dpx;height:%dpx;%s' % (size, size, extra) cls = "av org" if org else "av" if org: inner = orgmark(key) else: ph = photo_for(key) inner = ('' % (ph, photo_style(ph)) if ph else portrait(key)) return '
%s
' % (cls, st, inner) PEAKS = [12,28,44,61,73,58,39,66,88,74,52,35,47,69,81,63,41,29,55,77,90,68,44,31,50,72,84,59,37,25, 43,66,79,57,34,22,40,62,75,53,30,19,36,58,71,49,27,16,33,55,68,46,24,14,30,52,64,42,21,11] def wave(progress=0): """progress 0..1 — static rendering.""" n = len(PEAKS); cut = int(n * progress) bars = [] for i, p in enumerate(PEAKS): h = max(3, round(p * 0.26)) col = 'var(--accent)' if i < cut else 'var(--ink3)' bars.append('' % (h, col)) return '%s' % ''.join(bars) def voice(dur="0:47", lang="SW", progress=0.0): return ('
%s%s
' % ('', wave(progress), dur)) def tone(h, col, chip=None, ratio=None): inner = ('' % chip) if chip else '' return ('
%s
' % (h, col, inner)) def pfoot(geo, counts=("12","4","38"), extra=""): ics = [("reply", counts[0]), ("repost", counts[1]), ("heart", counts[2])] parts = ''.join('%s%s' % (icon(k, 15, 1.4), v) for k, v in ics) return '
%s%s%s
' % (geo, parts, extra) def geochip(flag, code): return '%s %s' % (flag, code) def card(initials, name, seal_kind, handle, ts, body_html, foot, dim=False, av_bg=None): s = seal(seal_kind) if seal_kind else '' st = ' style="color:var(--ink2)"' if dim else '' return ('
%s
' '
%s%s%s%s
' '%s
%s
' % (av(initials), name, s, handle, ts, st, body_html, foot)) # ---------- file template ---------- TPL = """ %(fonts)s
%(body)s
""" 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
%(tray)s
%(prompt)s
%(avtpl)s
{{p.name}} %(sealp)s %(sealo)s {{p.handle}} {{p.ts}}
{{p.text}}
{{p.dur}}
{{p.flag2}} {{p.code2}} {{p.flag}} {{p.code}} {{p.flag}} {{p.code}} {{p.lang}} %(icrep)s{{p.c1}} %(icrp)s{{p.c2}} %(ichr)s{{p.c3}}
%(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 += ('
' '
' '%s' '
%s%s' '%s%s
' '
%s
%s
' % (av(r["i"], 32), r["n"], SEAL_P if r["s"] == "person" else "", r["h"], r["t"], r["x"], '
%s%s%s%s%s%s
' % (icon("reply",15,1.4), r["ct"][0], icon("repost",15,1.4), r["ct"][1], icon("heart",15,1.4), r["ct"][2]))) DETAIL = """
%(appbar)s
%(avSM)s
Sahel Monitor%(sealo)s
@sahelmonitor
%(chev)s
%(text)s
%(geo)s
09:12 · 25 Aug 2026
412 replies1,204 reposts3,417 likes
%(r1)s%(r2)s %(r3)s%(r4)s
REPLIES · %(nreplies)d OF 412
%(replies)s
%(avYO32)s
Reply to Sahel Monitor
%(mic)s
""" write("PostDetail", DETAIL % dict(avSM=av("SM"), avYO32=av("YO", 32), appbar=appbar(title="Post", back=True, right=""), sealo=SEAL_O, chev=icon("chev",18), text=p0["x"], geo=geochip(p0["f"], p0["c"]), r1=icon("reply",20), r2=icon("repost",20), r3=icon("heart",20), r4=icon("send",20), mic=icon("mic",20), replies=rep_html, nreplies=len(replies))) # ---------- 3. Compose (voice state) ---------- COMPOSE = """
Cancel 82
%(avYO)s
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 MLI FR
""" 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 MLI FR
""" 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.
%(img)s %(mic)s %(flag)s MLI FR
""" write("MicState", MICSTATE % dict(img=icon("image",20), mic=icon("mic",20), flag="\U0001F1F2\U0001F1F1")) print("3c ok") # ---------- 4. Profile ---------- PROFILE = """
%(appbar)s
%(avAY88)s
%(mail)s
Amina Yusuf%(seal20)s
@aminay
Reporting on cross-border trade and the people who actually move the goods. Kiswahili and English. Voice notes most mornings.
%(flag)s KEN JOINED 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 IN Diaspora
%(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 += ('
' '%s' '
' '
%s%s%s
' '
' '
%s
%s
' % (av(ini, 48), nm, s, ts, prev, badge)) MESSAGES = """
%(appbar)s
%(rows)s
%(tabs)s
""" write("Messages", MESSAGES % dict(appbar=appbar(title="Messages", right="search"), rows=crows, tabs=tabbar("msg"))) # ---------- 7. Message thread ---------- def bubble(text, own=False, tag=None, ts=None): if own: return ('
' '
%s
' '
' '%s%s
' % (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 += ('
' '
%s
' '
' '
%s%s
' '
%s
%s
' % (ring, av(ini, 40, "width:100%;height:100%;"), nm, s, ago, lbl)) STORIES = """
%(appbar)s %(tray)s
%(avYO46)s
Your story
Posted 2 h ago · expires in 22 h
%(eye)s 214
FROM PEOPLE YOU FOLLOW
%(rows)s
%(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.
Reply to Amina
%(send)s
""" write("StoryViewer", VIEWER % dict(avAY34=av("AY", 34), segs=segs, close=icon("close",22), send=icon("send",22))) print("8-9 ok") # ---------- 10. Sign-up, step 4 of 4 ---------- def bullet(text, kind="get"): mark = ('' if kind == "get" else '') return ('
%s' '%s
' % (mark, text)) SIGNUP = """
%(back)s 4 / 4
Verification · optional

Connect your Federal Africa account

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 = """
AfriX EN FR SW AR
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
""" write("Settings", SET_SRC % dict(appbar=appbar(title="Settings", back=True, right=""), tabs_g=stabs, rows=srows_set, tabs=tabbar("home"))) print("12b-12c ok") # ---------- 13. Data-saver (live tap-to-load) ---------- DS_BODY = """
%(appbar)s
Slow connection — photos are off
%(rail)s
%(avtpl)s
{{p.name}} %(sealp)s {{p.handle}}{{p.ts}}
{{p.text}}
%(port)s
%(sky)s
{{p.flag}} {{p.code}} %(icrep)s{{p.c1}}%(icrp)s{{p.c2}}%(ichr)s{{p.c3}}
%(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", 26, 2)) return '
%s
' % ''.join(out) def ar_tray(): people = [("أ","أمينة",0),("ك","كوامي",0),("ث","ثانديوي",0),("ي","يوسف",1),("ف","فاطو",1)] cells = ['
' '
%s
' 'أنت
' % 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 ('
%s
' '
%s%s%s%s
' '%s%s
' % (av(ini), name, s, handle, ts, body, pfoot(geo, counts))) ARABIC = """
أفريكس %(search)s%(bell)s7 %(gear)s
%(rail)s %(tray)s
%(c1)s %(c2)s %(c3)s
%(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 ('' '
%s
' '
' % (flag, hint, body)) nav_crows = crows.replace( '
', '
') nav_srows = srows.replace( '
', '
') p_feed = MAIN_BODY % dict(avtpl=av_template("p", ' onClick="{{p.openProfile}}"'), prompt=prompt_row(nav=True), appbar=appbar(nav=True), tray=storytray(nav=True), fab=fab(nav=True), tabs=tabbar("home", nav=True), sealp=SEAL_P, sealo=SEAL_O, icrep=icon("reply",15,1.4), icrp=icon("repost",15,1.4), ichr=icon("heart",15,1.4)) p_detail = DETAIL % dict(avSM=av("SM"), avYO32=av("YO", 32),appbar=appbar(title="Post", back=True, right="", nav=True), sealo=SEAL_O, chev=icon("chev",18), text=p0["x"], geo=geochip(p0["f"], p0["c"]), r1=icon("reply",20), r2=icon("repost",20), r3=icon("heart",20), r4=icon("send",20), mic=icon("mic",20), replies=rep_html, nreplies=len(replies)) p_compose = (COMPOSE % dict(avYO=av("YO"), wave=wave(0.0), img=icon("image",20), mic=icon("mic",20), flag="\U0001F1F2\U0001F1F1") ).replace('Cancel', 'Cancel' ).replace('', '') p_compose = p_compose.replace( '', '') # The take in progress. Stop lands back on the review state, which is what Compose is. p_recording = (RECORDING % dict(avYO=av("YO"), img=icon("image",20), mic=icon("mic",20), flag="\U0001F1F2\U0001F1F1") ).replace('Cancel', 'Cancel' ).replace('Stop', 'Stop' ).replace(' 82
%(av)s
Second load cleared at 09:20. Photos of the new schedule board to follow.
Three minutes maximum
""" p_inline = (MAIN_BODY % dict(avtpl=av_template("p", ' onClick="{{p.openProfile}}"'), prompt=(INLINE_COMPOSER % dict(av=av("AY"))) + scard(OWN_POST, dim=True), appbar=appbar(nav=True), tray=storytray(nav=True), fab=fab(nav=True), tabs=tabbar("home", nav=True), sealp=SEAL_P, sealo=SEAL_O, icrep=icon("reply",15,1.4), icrp=icon("repost",15,1.4), ichr=icon("heart",15,1.4))) write("FeedComposing", MAIN_BODY % dict(avtpl=av_template("p"), prompt=(INLINE_COMPOSER % dict(av=av("AY"))).replace(' onClick="{{navBack}}"', "") + scard(OWN_POST, dim=True), appbar=appbar(), tray=storytray(), fab=fab(), tabs=tabbar("home"), 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("feed-composing ok") PROTO_BODY = (scr("isFeed", p_feed, "true") + scr("isDetail", p_detail) + scr("isCompose", p_compose) + scr("isProfile", p_profile) + scr("isExplore", p_explore) + scr("isMessages", p_msgs) + scr("isThread", p_thread) + scr("isStories", p_stories) + scr("isViewer", p_viewer) + scr("isSignup", p_signup) + scr("isRecording", p_recording) + scr("isInline", p_inline)) PROTO_LOGIC = """ componentDidMount() { this.setState({ screen: 'feed', stack: [], scope: 'all', playingId: null, prog: 0 }); } componentWillUnmount() { if (this._t) clearInterval(this._t); } go(name) { var st = this.state || {}; var stack = (st.stack || []).concat([st.screen || 'feed']); if (this._t) { clearInterval(this._t); this._t = null; } this.setState({ screen: name, stack: stack, playingId: null, prog: 0 }); } root(name) { if (this._t) { clearInterval(this._t); this._t = null; } this.setState({ screen: name, stack: [], playingId: null, prog: 0 }); } back() { var st = this.state || {}; var stack = (st.stack || []).slice(); var prev = stack.pop() || 'feed'; this.setState({ screen: prev, stack: stack, playingId: null, prog: 0 }); } 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 screen = st.screen || 'feed'; var scope = st.scope || 'all'; var scopes = SCOPES.map(function (s) { return { key: s.key, label: s.label, cells: s.cells, isDia: s.isDia, wrapStyle: s.isDia ? 'margin-inline-end:6px' : '', sel: s.key === scope ? '1' : '0', 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 () { self.go('detail'); }, openProfile: function (e) { if (e && e.stopPropagation) e.stopPropagation(); self.go('profile'); }, toggle: function (e) { if (e && e.stopPropagation) e.stopPropagation(); self.toggle(p.id); } }); }); return { theme: this.props.theme ?? 'light', scopes: scopes, posts: posts, isFeed: screen === 'feed', isDetail: screen === 'detail', isCompose: screen === 'compose', isProfile: screen === 'profile', isExplore: screen === 'explore', isMessages: screen === 'messages', isThread: screen === 'thread', isStories: screen === 'stories', isViewer: screen === 'viewer', isSignup: screen === 'signup', isRecording: screen === 'recording', isInline: screen === 'inline', navHome: function () { self.root('feed'); }, navFeedRoot: function () { self.root('feed'); }, navExplore: function () { self.root('explore'); }, navAlerts: function () {}, navMsg: function () { self.root('messages'); }, navYou: function () { self.go('profile'); }, navBack: function () { self.back(); }, navCompose: function () { self.go('compose'); }, navRecord: function () { self.go('recording'); }, navInline: function () { self.go('inline'); }, navSignup: function () { self.go('signup'); }, navStories: function () { self.go('stories'); }, navViewer: function () { self.go('viewer'); }, navThread: function () { self.go('thread'); } }; }""" write("Prototype", PROTO_BODY, logic=PROTO_LOGIC % dict(feed=js_feed(), scopes=js_scopes(), peaks=json.dumps(PEAKS))) print("prototype ok") # ===================================================================== # DELIVERABLE B — WEB # ===================================================================== WEB_CSS = """ .afx{width:1440px;height:1024px;font-size:15px} .wshell{flex:1 1 auto;display:flex;justify-content:center;gap:32px;overflow:hidden;padding:0 24px} .wrail{width:256px;flex:0 0 auto;display:flex;flex-direction:column;gap:2px;padding:22px 0; border-inline-end:1px solid var(--hair)} .wrail .wmark{font-family:"IBM Plex Sans Condensed","IBM Plex Sans",sans-serif;font-weight:700; font-size:26px;letter-spacing:-.01em;padding:0 12px 18px} .wnav{display:flex;align-items:center;gap:12px;padding:10px 12px;color:var(--ink2);font-size:15.5px; border-inline-start:2px solid transparent} .wnav[data-sel="1"]{color:var(--accent);font-weight:600;border-inline-start-color:var(--accent); background:var(--surface2)} .wnav .cnt{margin-inline-start:auto;font-family:"IBM Plex Mono",monospace;font-size:11px; background:var(--danger);color:var(--bg);min-width:20px;height:18px;display:flex;align-items:center; justify-content:center;padding:0 5px} .wcol{width:600px;flex:0 0 auto;display:flex;flex-direction:column;overflow:hidden; border-inline-end:1px solid var(--hair)} .wctx{width:340px;flex:0 0 auto;overflow-y:auto;padding:22px 0;scrollbar-width:none} .wctx::-webkit-scrollbar{display:none} .wcard{border:1px solid var(--hair);background:var(--surface);padding:16px;margin-bottom:16px} .wcard h4{margin:0 0 12px;font-family:"IBM Plex Mono",monospace;font-size:10px;letter-spacing:.12em; text-transform:uppercase;color:var(--ink3);font-weight:600} .wscope{display:flex;align-items:center;gap:10px;padding:7px 0;color:var(--ink2);font-size:14px} .wscope[data-sel="1"]{color:var(--accent);font-weight:600} .wscope .n{margin-inline-start:auto;font-family:"IBM Plex Mono",monospace;font-size:11px;color:var(--ink3)} .whead{display:flex;align-items:center;gap:12px;padding:0 20px;height:60px;flex:0 0 auto; border-bottom:1px solid var(--hair)} .whead .t{font-family:"IBM Plex Sans Condensed","IBM Plex Sans",sans-serif;font-weight:600;font-size:20px} .wfeed{flex:1 1 auto;overflow-y:auto;scrollbar-width:none} .wfeed::-webkit-scrollbar{display:none} .wcard-post{padding:16px 20px} .wtrend{display:grid;grid-template-columns:24px minmax(0,1fr);gap:10px;padding:9px 0; border-bottom:1px solid var(--hair)} .wtrend:last-child{border-bottom:0} .wthemesw{display:flex;gap:4px;margin-top:auto;padding:0 12px 8px} .wthemeopt{flex:1 1 0;min-height:44px;border:1px solid var(--hair);color:var(--ink3); font-family:"IBM Plex Mono",monospace;font-size:10px;letter-spacing:.04em;display:flex; align-items:center;justify-content:center;background:none} .wthemeopt[data-sel="1"]{border-color:var(--accent);color:var(--accent);font-weight:600} .wsignout{display:flex;align-items:center;min-height:44px;padding:0 12px;color:var(--ink3);font-size:13px} """ def web_rail(active="home"): items = [("home","Feed","home",""),("search","Explore","explore",""), ("bell","Notifications","alerts","7"),("mail","Messages","msg","3"), ("user","Profile","you",""),("gear","Settings","settings","")] out = ['
AfriX
'] for ic, label, key, cnt in items: badge = ('%s' % cnt) if cnt else '' out.append('
%s%s%s
' % ('1' if key == active else '0', hicon(ic, 21), label, badge)) out.append('' % icon("plus", 18, 2)) out.append('
' '
Light
' '
Dark
' '
System
') out.append('
' '%s
' '
Amina Yusuf
' '
@aminay
' % av("AY", 36)) out.append('
Sign out
') return '
%s
' % ''.join(out) def web_scope_card(active="all"): rows = "" counts = {"all": "4.2k", "north": "610", "west": "1.1k", "east": "980", "south": "740", "dia": "520"} for k, label in SCOPES: rows += ('
%s%s%s
' % ('1' if k == active else '0', glyph(k, 20), label, counts[k])) return ('

Scope

' '
%s
' '
%s
' % (glyph(active, 168, 5), rows)) def web_trending(scope_label="All Africa", trends=None): trends = trends or [("01","Tariff schedule","4,210 posts"),("02","#TemaPort","2,880 posts"), ("03","Clearing times","1,940 posts"),("04","#VoltaData","1,120 posts"), ("05","Harmattan","870 posts")] rows = "" for r, t, c in trends: rows += ('
%s
%s
' '
%s
' '
' % (r, t, c)) return '

Trending in %s

%s
' % (scope_label, rows) def web_follow(scope="all", scope_label="All Africa"): """Who to follow, in the context column. Scoped like everything else there: the heading names the scope and the list answers it, drawn from who posts into it. Verified accounts are NOT floated to the top (spec TRUST-1).""" seen, people = set(), [] for p in F[scope]: if p["h"] in seen: continue seen.add(p["h"]) people.append(p) if len(people) == 4: break rows = "" for p in people: s_ = SEAL_P if p.get("s") == "person" else (SEAL_O if p.get("s") == "org" else "") rows += ('
%s' '
%s%s
' '
%s
' 'Follow
' % (av(p["i"], 36), p["n"], s_, p["h"])) return ('

Who to follow in %s

%s' '
More accounts
' % (scope_label, rows)) def web_scoperail(active="all"): items = [] for k, label in SCOPES: items.append('' % ('1' if k == active else '0', glyph(k), label)) return '
%s
' % ''.join(items) WEB_CSS += """ .wfeed .card{padding:16px 20px} .wfeed .ptxt{font-size:16px;line-height:1.55} .wfeed .av{width:44px;height:44px} .wfeed .card{grid-template-columns:44px minmax(0,1fr);gap:14px} .lwrap{flex:1 1 auto;overflow-y:auto;scrollbar-width:none} .lwrap::-webkit-scrollbar{display:none} .lbar{height:68px;display:flex;align-items:center;gap:14px;padding:0 48px;flex:0 0 auto; border-bottom:1px solid var(--hair)} .lhero{display:grid;grid-template-columns:1fr 600px;gap:56px;padding:56px 48px 48px;align-items:start} .lsec{display:grid;grid-template-columns:repeat(3,1fr);gap:32px;padding:44px 48px 56px; border-top:1px solid var(--hair)} .lsec h3{font-family:"IBM Plex Sans Condensed","IBM Plex Sans",sans-serif;font-weight:600;font-size:20px; margin:0 0 10px;letter-spacing:-.005em} .lsec p{margin:0;font-size:14.5px;line-height:1.6;color:var(--ink2)} """ WEB_FEED = """
%(rail)s
Home afrix.app/feed/all-africa
%(scoperail)s
%(cards)s
%(scopecard)s%(trending)s%(follow)s
""" def web_cards(scope="all", n=6): return ''.join(scard(p) for p in F[scope][:n]) write("WebFeed", WEB_FEED % dict(rail=web_rail("home"), scoperail=web_scoperail("all"), cards=web_cards("all"), scopecard=web_scope_card("all"), trending=web_trending(), follow=web_follow("all", "All Africa")), extra_css=WEB_CSS, props=BASE_PROPS.replace('"width":390,"height":844', '"width":1440,"height":1024')) WEB_PROFILE = """
%(rail)s
%(back)s
Amina Yusuf 64,209 followers
%(av)s
%(mail)s
Amina Yusuf%(seal)s
@aminay
Reporting on cross-border trade and the people who actually move the goods. Kiswahili and English. Voice notes most mornings.
%(flag)s KEN JOINED MAR 2025
1,842 following64,209 followers
Posts
Replies
Media
%(posts)s

Verified identity

%(seal20)s
Identity verified by Federal Africa
CLASS  PERSON
COUNTRY  KENYA
ISSUED  11 MAR 2026
This account’s identity has been checked. It is not an endorsement of what they post.

Posts most often in

%(g1)sEast Africa71%%
%(g2)sAll Africa21%%
%(g3)sDiaspora8%%
""" write("WebProfile", WEB_PROFILE % dict( rail=web_rail("you"), back=icon("back"), av=av("AY", 112, "border:4px solid var(--bg);"), mail=icon("mail", 18), seal=seal("person", 22), seal20=seal("person", 20), flag="\U0001F1F0\U0001F1EA", posts=scard(F["all"][1]) + scard(F["all"][4]) + scard(F["east"][2]), g1=glyph("east", 20), g2=glyph("all", 20), g3=glyph("dia", 20)), extra_css=WEB_CSS, props=BASE_PROPS.replace('"width":390,"height":844', '"width":1440,"height":1024')) print("web feed + profile ok") WEB_EXPLORE = """
%(rail)s
%(search)sSearch AfriX
%(scoperail)s
TRENDING IN Diaspora afrix.app/explore/diaspora
%(trends)s
ACCOUNTS TO FOLLOW
%(accounts)s
""" DIA_TRENDS = [("01","Voting from abroad","6,120 posts"),("02","#RemittanceCosts","3,410 posts"), ("03","Dual nationality","2,180 posts"),("04","#HomeElections","1,760 posts"), ("05","Shipping to Lagos","940 posts"),("06","#ConsularQueues","780 posts"), ("07","Land title abroad","540 posts"),("08","#SendingItHome","410 posts")] trend_rows = "" for r, t, c in DIA_TRENDS: trend_rows += ('
%s
%s
' '
%s
' '
' % (r, t, c)) acc_rows = "" for ini, nm, hd, sk in [("CO","Chidi Okonkwo","@chidio","person"),("RD","Remittance Desk","@remitdesk","org"), ("DL","Diaspora Legal Aid","@dialegal","org"),("AB","Aïssatou Bâ","@aissatoub","")]: sl = SEAL_P if sk == "person" else (SEAL_O if sk == "org" else "") acc_rows += ('
%s
' '
%s%s
' '
%s
' '
' % (av(ini, 44), nm, sl, hd)) write("WebExplore", WEB_EXPLORE % dict(rail=web_rail("explore"), search=icon("search", 19), scoperail=web_scoperail("dia"), trends=trend_rows, accounts=acc_rows), extra_css=WEB_CSS, props=BASE_PROPS.replace('"width":390,"height":844', '"width":1440,"height":1024')) # ---------- logged-out landing ---------- WEB_LANDING = """
AfriX Sign in
A public record of the continent’s conversation

Africa’s conversation, on Africa’s terms.

Switch between West, East, North, Southern and the diaspora. Verified by a real identity service, not a subscription. Built to be read on the phone you already own.

No account needed to read · English, Français, Kiswahili, العربية

LIVE — ALL AFRICA
%(scoperail)s
%(cards)s
%(sealbig)s ISSUED, NOT BOUGHT

The badge means someone checked.

Link a Federal Africa account and the seal appears — a real, identity-checked person or organisation. It is not a subscription tier, it can be withdrawn, and tapping it tells you who issued it and when.

%(minivoice)s

Say it, if typing is slower.

Voice is a way to post, not an attachment. It sits in the feed at the height of a short paragraph, carries its language, and never plays until you ask it to.

Photo · 24 KB — Tap to load

You decide what a photo costs.

Images stay unloaded until you want them, and we tell you the real size in kilobytes first. There is no data-saver switch to find, because nothing is heavy by default.

""" mini_wave = ('
' '%s' '0:47
' % wave(0.35)) write("WebLanding", WEB_LANDING % dict(scoperail=web_scoperail("all"), cards=web_cards("all", 4), sealbig=seal("person", 22), minivoice=mini_wave), extra_css=WEB_CSS, props=BASE_PROPS.replace('"width":390,"height":844', '"width":1440,"height":1024')) print("explore + landing ok") # ---------- responsive: the same feed at 768 and 390 ---------- WEB_768_CSS = WEB_CSS + """ .afx{width:768px;height:1024px} .wshell{padding:0} .wrail{width:88px;padding:18px 0;align-items:stretch} .wrail .wmark{font-size:20px;text-align:center;padding:0 0 16px} .wnav{flex-direction:column;gap:5px;padding:10px 4px;font-size:10px;justify-content:center; border-inline-start:0;border-inline-end:2px solid transparent;text-align:center} .wnav[data-sel="1"]{border-inline-end-color:var(--accent);border-inline-start-color:transparent} .wnav .cnt{position:absolute} .wcol{width:680px;border-inline-end:0} .wthemesw{flex-direction:column;gap:2px;padding:0 4px 6px} """ WEB_768 = """
%(rail)s
Home 768 — context column drops, scope moves into the feed
%(scoperail)s
%(cards)s
""" def rail768(active="home"): items = [("home","Feed","home"),("search","Explore","explore"),("bell","Alerts","alerts"), ("mail","Messages","msg"),("user","Profile","you"),("gear","Settings","settings")] out = ['
AfriX
'] for ic, label, key in items: out.append('
%s%s
' % ('1' if key == active else '0', hicon(ic, 22), label)) out.append('' % icon("plus", 24, 2)) # Compact foot: three labels side by side measure 114px in an 88px rail, so # they stack, and the account row drops to the avatar alone (WEB-1). out.append('
' '
Light
' '
Dark
' '
System
') out.append('
%s
' % av("AY", 36)) out.append('
' 'Sign out
') return '
%s
' % ''.join(out) write("WebFeed768", WEB_768 % dict(rail=rail768("home"), scoperail=web_scoperail("all"), cards=web_cards("all", 6)), extra_css=WEB_768_CSS, props=BASE_PROPS.replace('"width":390,"height":844', '"width":768,"height":1024')) # 390 — the web app becomes the mobile shell exactly (WEB-1) WEB_390 = """
%(appbar)s %(rail)s %(tray)s
%(cards)s
%(tabs)s
""" write("WebFeed390", WEB_390 % dict(appbar=appbar(), rail=rail("all"), tray=storytray(), cards=web_cards("all", 6), tabs=tabbar("home"))) print("responsive ok")