| … | … | @@ -810,6 +810,9 @@ a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible, |
| 810 | 810 | --fg:#111; --fg-dim:#5c6672; --fg-faint:#6b7480; |
| 811 | 811 | --bg:#fff; --bg-soft:#f7f8fa; --line:#e6e8eb; --line-soft:#f0f1f3; |
| 812 | 812 | --accent:#1a56db; --like:#e0245e; --repost:#17bf63; |
| 813 | /* Paired with --accent and with a text input's background, so the theme block only has to restate |
| 814 | tokens rather than restate rules. */ |
| 815 | --bg-input:#fff; --on-accent:#fff; |
| 813 | 816 | /* Semantic colours. These existed only as literals, which is why the dark patch below had to |
| 814 | 817 | hand-repaint every element that used one. Measured on --bg #fff: --danger-text 6.47:1 and --lens |
| 815 | 818 | 5.67:1 pass AA for text. Two were corrected on the way in rather than tokenising a known-failing |
| … | … | @@ -864,6 +867,19 @@ nav a[aria-current=page]{color:var(--fg);font-weight:600;border-bottom-color:var |
| 864 | 867 | /* Controls row. Small gap on purpose: each control is its own 44x44 box, so the whitespace between |
| 865 | 868 | them is padding INSIDE a target, not dead space between two small ones. */ |
| 866 | 869 | .meta{display:flex;align-items:center;gap:var(--s1);flex-wrap:wrap;margin-top:var(--s2);font-size:var(--f0);color:var(--fg-faint)} |
| 870 | /* ── appearance control ──────────────────────────────────────────────────────────────────────────── |
| 871 | A segmented row of submit buttons, one per state. Buttons rather than a select because the current |
| 872 | value should be visible without opening anything, and 44px because this is a touch control like any |
| 873 | other. */ |
| 874 | .themesec{margin:0 0 var(--s5)} |
| 875 | .sech{font-size:var(--f2);margin:0 0 var(--s2)} |
| 876 | .themeform{display:flex;gap:0;border:1px solid var(--line);border-radius:999px;overflow:hidden;width:fit-content} |
| 877 | .themebtn{font:inherit;font-size:var(--f1);min-height:44px;padding:0 var(--s4);border:0; |
| 878 | background:var(--bg);color:var(--fg-dim);cursor:pointer} |
| 879 | .themebtn+.themebtn{border-left:1px solid var(--line)} |
| 880 | .themebtn:hover{background:var(--bg-soft);color:var(--fg)} |
| 881 | .themebtn.on{background:var(--accent);color:var(--on-accent);font-weight:600} |
| 882 | .themenote{font-size:var(--f0);color:var(--fg-faint);margin:var(--s2) 0 0;max-width:44ch} |
| 867 | 883 | /* ── composer ────────────────────────────────────────────────────────────────────────────────────── |
| 868 | 884 | The composer was ~40% of the first screen on a phone, permanently expanded, with four gate selects |
| 869 | 885 | competing with the timeline before a reader had seen a single post. The gates move behind a native |
| … | … | @@ -935,20 +951,47 @@ nav a[aria-current=page]{color:var(--fg);font-weight:600;border-bottom-color:var |
| 935 | 951 | .media.multi{gap:var(--s1)} |
| 936 | 952 | .meta{gap:var(--s2)} |
| 937 | 953 | } |
| 954 | /* ── theme ───────────────────────────────────────────────────────────────────────────────────────── |
| 955 | THREE states, not two: system (no attribute), explicit light, explicit dark. |
| 956 | |
| 957 | It cannot be prefers-color-scheme alone. Android's WebView does not report that query truthfully to |
| 958 | page content unless the app opts in (setAlgorithmicDarkeningAllowed, or the deprecated |
| 959 | setForceDark), and favela-android does neither — so the phone has ALWAYS been told "light" whatever |
| 960 | the handset is set to. An explicit attribute does not consult the query at all, which is why the |
| 961 | settings toggle and the Android bug are one fix. |
| 962 | |
| 963 | The attribute is written server-side from a cookie, so the first paint is already correct — a |
| 964 | class flipped by script would flash white on every navigation. |
| 965 | |
| 966 | Only the token block is duplicated. The rules that used to live in here alongside it now read from |
| 967 | --bg-input and --on-accent, so there is one place per theme rather than one place per component — |
| 968 | the same reason the hand-written dark patch was deleted earlier. */ |
| 938 | 969 | @media(prefers-color-scheme:dark){ |
| 939 | | :root{--fg:#e8eaed;--fg-dim:#9aa4b2;--fg-faint:#79838f;--bg:#111;--bg-soft:#191b1f; |
| 940 | | --line:#2b2f36;--line-soft:#23262b;--accent:#9ec5ff; |
| 941 | | /* Measured on --bg #111: danger-text 10.95:1, ok 10.84:1, bookmark 8.85:1, lens 9.47:1. |
| 942 | | --danger keeps its light value because it is a fixed button background that white sits on |
| 943 | | (5.46:1) rather than something read against the page. */ |
| 944 | | --danger:#c92a2a;--danger-text:#f8b4b4;--danger-bg:#4a1e1e; |
| 945 | | --warn:#e3b341;--ok:#4ade80;--bookmark:#f59f00;--badge-bg:#1e3a5f; |
| 946 | | --lens:#a5b4fc;--lens-line:#3b4252;--lens-edge:#2d3a5f} |
| 947 | | .post .media img{border-color:var(--line)} |
| 948 | | .composer textarea{background:#0e0f12;color:var(--fg)} |
| 949 | | #post-btn{background:var(--accent);color:#0b0d10} |
| 950 | | .more:hover{background:var(--bg-soft)} |
| 970 | /* System dark, unless the reader explicitly chose light. */ |
| 971 | :root:not([data-theme="light"]){--fg:#e8eaed;--fg-dim:#9aa4b2;--fg-faint:#79838f;--bg:#111;--bg-soft:#191b1f; |
| 972 | --line:#2b2f36;--line-soft:#23262b;--accent:#9ec5ff; |
| 973 | --bg-input:#0e0f12;--on-accent:#0b0d10; |
| 974 | /* Measured on --bg #111: danger-text 10.95:1, ok 10.84:1, bookmark 8.85:1, lens 9.47:1. |
| 975 | --danger keeps its light value because it is a fixed button background that white sits on |
| 976 | (5.46:1) rather than something read against the page. */ |
| 977 | --danger:#c92a2a;--danger-text:#f8b4b4;--danger-bg:#4a1e1e; |
| 978 | --warn:#e3b341;--ok:#4ade80;--bookmark:#f59f00;--badge-bg:#1e3a5f; |
| 979 | --lens:#a5b4fc;--lens-line:#3b4252;--lens-edge:#2d3a5f} |
| 951 | 980 | } |
| 981 | /* Explicit dark wins in BOTH directions: it must also override a light system preference. */ |
| 982 | :root[data-theme="dark"]{--fg:#e8eaed;--fg-dim:#9aa4b2;--fg-faint:#79838f;--bg:#111;--bg-soft:#191b1f; |
| 983 | --line:#2b2f36;--line-soft:#23262b;--accent:#9ec5ff; |
| 984 | --bg-input:#0e0f12;--on-accent:#0b0d10; |
| 985 | /* Measured on --bg #111: danger-text 10.95:1, ok 10.84:1, bookmark 8.85:1, lens 9.47:1. |
| 986 | --danger keeps its light value because it is a fixed button background that white sits on |
| 987 | (5.46:1) rather than something read against the page. */ |
| 988 | --danger:#c92a2a;--danger-text:#f8b4b4;--danger-bg:#4a1e1e; |
| 989 | --warn:#e3b341;--ok:#4ade80;--bookmark:#f59f00;--badge-bg:#1e3a5f; |
| 990 | --lens:#a5b4fc;--lens-line:#3b4252;--lens-edge:#2d3a5f} |
| 991 | .post .media img{border-color:var(--line)} |
| 992 | .composer textarea{background:var(--bg-input);color:var(--fg)} |
| 993 | #post-btn{background:var(--accent);color:var(--on-accent)} |
| 994 | .more:hover{background:var(--bg-soft)} |
| 952 | 995 | |
| 953 | 996 | /* ── error surface ──────────────────────────────────────────────────────────────────────────────── |
| 954 | 997 | A failed write used to be a 1.2s flash plus a title attribute, which needs a hover — so on touch it did not |
| … | … | @@ -1084,9 +1127,34 @@ ${lists.map((l) => `<option value="${esc(l.uri)}">only: ${esc(l.name)}</option>` |
| 1084 | 1127 | // Settings: the avatar. This exists because a lost avatar is otherwise unfixable — the blob bytes are |
| 1085 | 1128 | // gone from every node, so nothing can restore it except the owner re-uploading. Doing so also lands |
| 1086 | 1129 | // the first blob in the content-addressed OverlayBlobStore, so it migrates as a side effect. |
| 1087 | | export function settingsPage ({ viewer = null, profile = null, did = null, devices = [] } = {}) { |
| 1130 | // Appearance. Deliberately available SIGNED OUT as well: a theme is a property of the reader's eyes, |
| 1131 | // not of an account, and the public instance serves mostly anonymous readers. |
| 1132 | // |
| 1133 | // Three states, and "system" is the ABSENCE of a preference rather than a third stored value — so a |
| 1134 | // reader who never touches this is in exactly the state everyone was in before it existed. |
| 1135 | // |
| 1136 | // A plain form with a submit per option: no JS, one tap, and it works in the Android WebView, which is |
| 1137 | // the case that prompted it. `return` sends you back where you were. |
| 1138 | export function themeControl (theme = null, returnTo = '/settings') { |
| 1139 | const opts = [ |
| 1140 | ['system', 'System', 'follow the device setting'], |
| 1141 | ['light', 'Light', ''], |
| 1142 | ['dark', 'Dark', ''] |
| 1143 | ] |
| 1144 | const cur = (theme === 'light' || theme === 'dark') ? theme : 'system' |
| 1145 | const buttons = opts.map(([v, label, hint]) => { |
| 1146 | const on = v === cur |
| 1147 | return `<button class="themebtn${on ? ' on' : ''}" name="theme" value="${v}"${on ? ' aria-current="true"' : ''}${hint ? ` title="${esc(hint)}"` : ''}>${esc(label)}</button>` |
| 1148 | }).join('') |
| 1149 | return `<section class="themesec"><h2 class="sech">Appearance</h2> |
| 1150 | <form class="themeform" method="POST" action="/api/theme"><input type="hidden" name="return" value="${esc(returnTo)}">${buttons}</form> |
| 1151 | <p class="themenote">System follows your device. On Android the in-app browser does not report that setting, so choose Light or Dark there.</p></section>` |
| 1152 | } |
| 1153 | |
| 1154 | export function settingsPage ({ viewer = null, profile = null, did = null, devices = [], theme = null } = {}) { |
| 1088 | 1155 | if (!viewer) { |
| 1089 | | return page({ title: 'settings · favela', viewer, active: null, body: '<div class="viewhdr">Settings <a href="/">← back</a></div><p class="empty">Sign in to manage your profile. <a href="/login">sign in →</a></p>' }) |
| 1156 | // Appearance still renders here — it is not an account setting. |
| 1157 | return page({ title: 'settings · favela', viewer, active: null, theme, body: '<div class="viewhdr">Settings <a href="/">← back</a></div>' + themeControl(theme) + '<p class="empty">Sign in to manage your profile. <a href="/login">sign in →</a></p>' }) |
| 1090 | 1158 | } |
| 1091 | 1159 | const cid = profile && profile.avatar && blobCid(profile.avatar.ref) |
| 1092 | 1160 | const current = (cid && did && isSafeCid(cid)) |
| … | … | @@ -1107,7 +1175,9 @@ export function settingsPage ({ viewer = null, profile = null, did = null, devic |
| 1107 | 1175 | title: 'settings · favela', |
| 1108 | 1176 | viewer, |
| 1109 | 1177 | active: null, |
| 1178 | theme, |
| 1110 | 1179 | body: `<div class="viewhdr">Settings <a href="/">← back</a></div> |
| 1180 | ${themeControl(theme)} |
| 1111 | 1181 | <section class="authbox"> |
| 1112 | 1182 | <h3>Profile picture</h3> |
| 1113 | 1183 | <p class="empty">Signed in as <strong>${esc(viewer.did)}</strong>${profile && profile.displayName ? ' · ' + esc(profile.displayName) : ''}</p> |
| … | … | @@ -1304,8 +1374,12 @@ const POSTCLICK = `<script> |
| 1304 | 1374 | |
| 1305 | 1375 | // Full HTML document. `client` (optional) is the progressive-enhancement <script> src/inline appended |
| 1306 | 1376 | // before </body>; with it absent the page is fully functional server-rendered. |
| 1307 | | export function page ({ title = 'favela', heading, viewer = null, active = 'timeline', canWrite = false, body = '', clientScript = '', quoting = null, pds = 'https://pds.7rnx.net', composerLists = [], freshness = null } = {}) { |
| 1308 | | return `<!doctype html><html lang="en"><head><meta charset="utf-8"> |
| 1377 | export function page ({ title = 'favela', heading, viewer = null, active = 'timeline', canWrite = false, body = '', clientScript = '', quoting = null, pds = 'https://pds.7rnx.net', composerLists = [], freshness = null, theme = null } = {}) { |
| 1378 | // Written server-side so the FIRST paint is already correct. A class flipped by script would flash |
| 1379 | // the wrong theme on every navigation, and with scripting off would never apply at all. Absent |
| 1380 | // attribute = follow the system, which is the default and the only state that existed before. |
| 1381 | const themeAttr = (theme === 'dark' || theme === 'light') ? ` data-theme="${theme}"` : '' |
| 1382 | return `<!doctype html><html lang="en"${themeAttr}><head><meta charset="utf-8"> |
| 1309 | 1383 | <meta name="viewport" content="width=device-width,initial-scale=1"> |
| 1310 | 1384 | <title>${esc(title)}</title> |
| 1311 | 1385 | <link rel="alternate" type="application/atom+xml" title="favela timeline" href="/feed.xml"> |