source: Klonkt/src/views/pages/admin-site-edit.ejs@ e46ebaa

main
Last change on this file since e46ebaa was e46ebaa, checked in by Robin <roboburr@…>, 3 weeks ago

Lezen: snappen dat je laat lezen, en geen lege schermen meer

Barts meldingen van 20 augustus, in een stuk of zes rondes bijgesteld. De
uitkomst is korter dan de weg ernaartoe, en die weg is het waard om vast te
leggen omdat ik er twee keer overheen ben geschoten.

SNAPPEN. De regel is nu in drie zinnen te zeggen:

omhoog scrollen nooit snappen -- je zoekt iets terug en bepaalt zelf

waar je stopt

omlaag, ver weg vrij scrollen
omlaag, laatste
150px van een
bericht vangt, en je landt op de bovenkant van het volgende

Er is geen top-zone: de bovenkant van het bericht waar je IN zit is geen
doelwit meer. Dat was de oorspronkelijke klacht -- je las een paar regels
verder en proximity trok je terug naar de bovenrand, waardoor het leek of
het scrollen vastliep. De grens die wel vangt is de onderkant van je huidige
bericht, en dat is hetzelfde punt als de bovenkant van het volgende.

Onderweg twee doodlopende wegen, allebei door Robin gecorrigeerd:

  • Eerst zette ik de snap UIT voor berichten langer dan het scherm. Op een telefoon is bijna elk bericht net iets langer dan de viewport, dus daar verdween het snappen overal: op 812px hoog kregen berichten van 1009 en zelfs 839 al geen snap meer.
  • Toen probeerde ik de vangzone aan de VOET van het bericht te hangen ("ben je de reacties voorbij"). Bij een kort bericht staat die voet middenin de lege ruimte die min-height maakte, dus de zone begon op een willekeurige plek. Gelukkig ving Robin dat voordat het uitgerold stond.

Het moest dus een getal in pixels zijn, en 150 is dat getal.

GEEN LEGE SCHERMEN MEER. min-height:100svh rekte elk bericht op tot een vol
scherm. Bij een kort bericht gaf dat een halve pagina leegte onder "Replies
and reactions" -- je keek naar niets en moest doorscrollen om te ontdekken
dat er nog iets kwam. Weg dus: berichten hebben hun eigen hoogte (gemeten:
360/1009/839/250 waar het eerst 888/888/888/888 was) en de stroom is gewoon
bericht na bericht. Het snappen hangt aan scroll-snap-align en niet aan die
hoogte, dus dat werkt onveranderd.

VLOEIEND: scroll-behavior:smooth, want zonder dat springt een snap er in een
frame naartoe en voelt het als een hik. Uit bij prefers-reduced-motion.

TERUG NAAR BOVEN, rechtsonder, in twee stappen: eerst naar het begin van DIT
bericht, daarna pas naar de kop van de pagina. Die knop is de enige die naar
een bovenkant mag springen, en hij heeft het snappen daar niet voor nodig --
scrollIntoView stuurt zelf. Nagemeten, juist omdat omhoog-snappen uit staat:
1706 -> 1206, exact de bovenkant.

COVERS nemen de volle kolombreedte met de verhouding intact. De ronde ervoor
haalde het opblazen eruit maar liet kleine afbeeldingen op ware grootte
staan, en dat was als postzegel in een brede kolom te klein.

DE CIRKEL. Lezen blijft daar dood (berichten van anderen), maar een tijdlijn
werkt er juist bij uitstek. Die dwong ik eerst af zodra de site geen
Lezen-site was, en dan kon je in de cirkel geen Grid meer kiezen. Nu is Grid
de landing en staat Tijdlijn ernaast: bij feed_alt_view=timeline altijd, bij
auto op desktop. De cirkel krijgt zijn eigen geheugen terug, met een eigen
sleutel zodat een keuze daar je thuisweergave niet aanraakt.

style.css naar v77 en MOD_V naar 13.

Suite 1165/1165.

Co-Authored-By: Claude Opus 5 <noreply@…>

  • Property mode set to 100644
File size: 22.0 KB
Line 
1<div class="container admin-site-edit-page">
2 <p><a href="/admin" class="btn">&larr; <%= t('asite.back_admin') %></a></p>
3 <h1><%= isNew ? t('asite.title_new') : t('asite.title_edit') + ': ' + site.title %></h1>
4
5 <% if (typeof success !== 'undefined' && success) { %><div class="audio-flash audio-flash--ok"><%= success %></div><% } %>
6 <% if (error) { %><div class="audio-flash audio-flash--err"><%= error %></div><% } %>
7
8 <form method="post" action="<%= isNew ? '/admin/sites/create' : '/admin/sites/' + site.slug + '/save' %>" class="site-form">
9
10 <fieldset>
11 <legend><%= t('asite.identity') %></legend>
12 <label>
13 <span><%= t('asite.slug') %> <% if (!isNew) { %><em><%= t('asite.slug_fixed') %></em><% } %></span>
14 <span class="slug-url">
15 <span class="slug-url-host" id="slug-host"
16 data-prefix="/">website.com/</span><input
17 type="text" name="slug" id="slug-input" class="slug-url-input" value="<%= site.slug %>" required <% if (!isNew) { %>readonly<% } %>
18 pattern="[a-z0-9_-]{2,40}" placeholder="<%= t('asite.slug_placeholder') %>"
19 autocapitalize="none" autocorrect="off" spellcheck="false">
20 </span>
21 </label>
22 <label>
23 <span><%= t('asite.field_title') %> <small class="form-hint-inline"><%= t('asite.field_title_hint') %></small></span>
24 <input type="text" name="title" value="<%= site.title || '' %>" required maxlength="200">
25 </label>
26 <label>
27 <span><%= t('asite.tagline') %> <small class="form-hint-inline"><%= t('asite.tagline_hint') %></small></span>
28 <input type="text" name="tagline" value="<%= site.tagline || '' %>" maxlength="200">
29 </label>
30 <label>
31 <span><%= t('asite.bio') %> <small class="form-hint-inline"><%= t('asite.bio_hint') %></small></span>
32 <textarea name="description" rows="3" maxlength="500"><%= site.description || '' %></textarea>
33 </label>
34 <label>
35 <span><%= t('asite.profile_photo') %></span>
36 <div class="photo-picker" id="photo-picker">
37 <div class="photo-thumb" id="photo-thumb" <%= site.profile_photo ? '' : 'data-empty' %>>
38 <img id="photo-preview" src="<%= avatar(site.profile_photo || '', 128) %>" alt="" <%= site.profile_photo ? '' : 'hidden' %>>
39 <span class="photo-empty" aria-hidden="true">
40 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
41 </span>
42 </div>
43 <div class="photo-controls">
44 <input type="text" name="profile_photo" id="photo-url" value="<%= site.profile_photo || '' %>" placeholder="<%= t('asite.photo_url_placeholder') %>">
45 <div class="photo-actions">
46 <button type="button" class="btn" id="photo-upload-trigger"><%= t('asite.photo_upload') %></button>
47 <button type="button" class="btn" id="photo-clear" <%= site.profile_photo ? '' : 'hidden' %>><%= t('asite.photo_clear') %></button>
48 <small id="photo-status" class="form-hint photo-status"></small>
49 </div>
50 <input type="file" id="photo-upload-field" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
51 </div>
52 </div>
53 </label>
54 <label>
55 <span><%= t('asite.language') %></span>
56 <input type="text" name="language" value="<%= site.language || 'nl' %>" maxlength="8">
57 </label>
58 <label class="cb">
59 <input type="checkbox" name="profile_enabled" value="1" <%= site.profile_enabled ? 'checked' : '' %>>
60 <span><%= t('asite.profile_enabled') %></span>
61 </label>
62 </fieldset>
63
64 <fieldset>
65 <legend><%= t('asite.appearance') %></legend>
66
67 <%# ── Curated accent picker ───────────────────────────────────────
68 Was a free-form <input type="color">; now a fixed set of 8 hand-
69 picked colors that work in both light and dark themes. The hex is
70 still what's stored in the DB — we just constrain WHICH hex. %>
71 <fieldset class="accent-grid">
72 <legend><%= t('asite.accent_color') %></legend>
73 <% const _curAccent = (site.accent || '#c2410c').toLowerCase(); %>
74 <% accents.forEach(function(a) { %>
75 <label class="accent-swatch" title="<%= a.name %>">
76 <input type="radio" name="accent" value="<%= a.color %>" <%= a.color.toLowerCase() === _curAccent ? 'checked' : '' %>>
77 <span class="accent-dot" style="background: <%= a.color %>;" aria-hidden="true"></span>
78 <span class="accent-swatch-name"><%= a.name %></span>
79 </label>
80 <% }); %>
81 </fieldset>
82
83 <%# ── Default theme for new visitors ───────────────────────────
84 'auto' (empty value) lets the browser/OS decide via prefers-color-scheme.
85 'light' / 'dark' force the site default for visitors who haven't picked
86 their own preference yet. Logged-in users with users.theme set, and
87 visitors with a localStorage override, still win over this default. %>
88 <fieldset class="theme-default">
89 <legend><%= t('asite.theme_default') %></legend>
90 <% const td = site.theme_override || ''; %>
91 <label class="cb">
92 <input type="radio" name="theme_override" value="" <%= td === '' ? 'checked' : '' %>>
93 <span><%= t('asite.theme_auto') %></span>
94 </label>
95 <label class="cb">
96 <input type="radio" name="theme_override" value="light" <%= td === 'light' ? 'checked' : '' %>>
97 <span><%= t('asite.theme_light') %></span>
98 </label>
99 <label class="cb">
100 <input type="radio" name="theme_override" value="dark" <%= td === 'dark' ? 'checked' : '' %>>
101 <span><%= t('asite.theme_dark') %></span>
102 </label>
103 </fieldset>
104
105 <fieldset class="palette-grid">
106 <legend><%= t('asite.palette') %></legend>
107 <% palettes.forEach(function(p) { %>
108 <label class="palette-swatch">
109 <input type="radio" name="palette" value="<%= p.key %>" <%= (site.palette || 'klonkt') === p.key ? 'checked' : '' %>>
110 <span class="palette-preview" aria-hidden="true">
111 <span class="pp-half pp-light" style="background: <%= p.light.paper %>; color: <%= p.light.ink %>;">
112 <span class="pp-letter">Aa</span>
113 <span class="pp-accent" style="background: <%= p.light.accent %>;"></span>
114 </span>
115 <span class="pp-half pp-dark" style="background: <%= p.dark.paper %>; color: <%= p.dark.ink %>;">
116 <span class="pp-letter">Aa</span>
117 <span class="pp-accent" style="background: <%= p.dark.accent %>;"></span>
118 </span>
119 </span>
120 <span class="palette-swatch-name"><%= p.name %></span>
121 </label>
122 <% }); %>
123 </fieldset>
124 </fieldset>
125
126 <%# Live preview: selecting an accent / theme / palette re-themes this page
127 instantly (reverts on reload; persists on save). %>
128 <%# Het script van deze pagina staat in assets/js/mod/admin-site-edit.js (shaer-bqr). %>
129
130 <fieldset>
131 <legend><%= t('asite.behavior') %></legend>
132 <label class="cb">
133 <input type="checkbox" name="is_public" value="1" <%= site.is_public ? 'checked' : '' %>>
134 <span><%= t('asite.is_public') %></span>
135 </label>
136 <label class="cb">
137 <input type="checkbox" name="robots_index" value="1" <%= site.robots_index ? 'checked' : '' %>>
138 <span><%= t('asite.robots_index') %></span>
139 </label>
140 <% if (typeof audioEnabled === 'undefined' || audioEnabled) { %>
141 <label class="cb">
142 <input type="checkbox" name="enable_audio_player" value="1" <%= site.enable_audio_player ? 'checked' : '' %>>
143 <span><%= t('asite.enable_audio') %></span>
144 </label>
145 <% } %>
146 <label class="cb">
147 <input type="checkbox" name="approve_followers" value="1" <%= site.approve_followers ? 'checked' : '' %>>
148 <span><%= t('asite.approve_followers') %></span>
149 </label>
150 </fieldset>
151
152 <fieldset class="profile-links-fieldset">
153 <legend><%= t('asite.links') %></legend>
154 <p class="form-hint"><%= t('asite.links_hint') %></p>
155
156 <div id="profile-links-rows">
157 <% (parsedLinks || []).forEach(function(l) { %>
158 <div class="profile-link-row">
159 <select name="profile_link_platform[]">
160 <% platforms.forEach(function(p) { %>
161 <option value="<%= p.key %>" <%= l.platform === p.key ? 'selected' : '' %>><%= p.label %></option>
162 <% }); %>
163 </select>
164 <input type="text" name="profile_link_url[]" value="<%= l.url %>" placeholder="https://…" required>
165 <button type="button" class="btn btn-danger pl-remove">×</button>
166 </div>
167 <% }); %>
168 </div>
169
170 <template id="profile-link-template">
171 <div class="profile-link-row">
172 <select name="profile_link_platform[]">
173 <% platforms.forEach(function(p) { %>
174 <option value="<%= p.key %>"><%= p.label %></option>
175 <% }); %>
176 </select>
177 <input type="text" name="profile_link_url[]" placeholder="https://…" required>
178 <button type="button" class="btn btn-danger pl-remove">×</button>
179 </div>
180 </template>
181
182 <button type="button" id="profile-link-add" class="btn"><%= t('asite.link_add') %></button>
183 </fieldset>
184
185 <%# Aliassen en verhuizen staan sinds 14-8 op /admin/migrate, bij elkaar met
186 exporteren, importeren en ophalen (Robin: alle migratie-opties op de
187 migrate-pagina). Ze hoorden hier ook niet thuis: dit scherm gaat over hoe
188 je site eruitziet, en een verhuizing is geen uiterlijk. Alleen de
189 wegwijzer blijft, want wie ze hier zocht moet ze kunnen vinden. %>
190 <fieldset>
191 <legend><%= t('asite.aliases') %></legend>
192 <p class="form-hint"><%= t('asite.moved_to_migrate') %></p>
193 <p><a href="/admin/migrate" class="btn"><%= t('admin.b_migrate') %></a></p>
194 </fieldset>
195
196 <fieldset>
197 <legend><%= t('asite.feed_view') %></legend>
198 <%# Twee vragen, en ze zijn niet hetzelfde: WELKE tweede weergave je site
199 aanbiedt naast Grid, en waarin hij opent. %>
200 <label>
201 <span><%= t('asite.feed_alt') %></span>
202 <select name="feed_alt_view">
203 <% var _a = ['timeline','auto'].indexOf(site.feed_alt_view) >= 0 ? site.feed_alt_view : 'reader'; %>
204 <option value="reader" <%= _a === 'reader' ? 'selected' : '' %>><%= t('asite.feed_alt_reader') %></option>
205 <option value="timeline" <%= _a === 'timeline' ? 'selected' : '' %>><%= t('asite.feed_alt_timeline') %></option>
206 <option value="auto" <%= _a === 'auto' ? 'selected' : '' %>><%= t('asite.feed_alt_auto') %></option>
207 </select>
208 </label>
209 <label>
210 <span><%= t('asite.feed_default') %></span>
211 <select name="feed_view_default">
212 <option value="reader" <%= site.feed_view_default !== 'grid' ? 'selected' : '' %>><%= t('asite.feed_alt_' + _a) %></option>
213 <option value="grid" <%= site.feed_view_default === 'grid' ? 'selected' : '' %>><%= t('asite.feed_grid') %></option>
214 </select>
215 </label>
216 <label class="cb">
217 <input type="checkbox" name="feed_view_switch" value="1" <%= site.feed_view_switch ? 'checked' : '' %>>
218 <span><%= t('asite.feed_switch') %></span>
219 </label>
220 <label class="cb">
221 <input type="checkbox" name="show_search" value="1" <%= site.show_search ? 'checked' : '' %>>
222 <span><%= t('asite.show_search') %></span>
223 </label>
224 <label class="cb">
225 <input type="checkbox" name="show_archive_link" value="1" <%= site.show_archive_link ? 'checked' : '' %>>
226 <span><%= t('asite.show_archive') %></span>
227 </label>
228 </fieldset>
229
230 <fieldset class="seo-pointer">
231 <legend><%= t('asite.seo') %></legend>
232 <p><%= t('asite.seo_pointer') %> <a href="/admin/seo"><%= t('asite.seo_link') %></a>.</p>
233 </fieldset>
234
235 <fieldset>
236 <legend><%= t('asite.custom_legend') %> <em><%= t('asite.optional') %></em></legend>
237 <label>
238 <span><%= t('asite.custom_css') %></span>
239 <textarea name="custom_css" rows="3" placeholder=".foo { color: red; }"><%= site.custom_css || '' %></textarea>
240 </label>
241 <label>
242 <span><%= t('asite.custom_head') %></span>
243 <textarea name="custom_head_html" rows="3" placeholder="<meta name=&quot;...&quot;>"><%= site.custom_head_html || '' %></textarea>
244 </label>
245 <label>
246 <span><%= t('asite.custom_foot') %></span>
247 <textarea name="custom_foot_html" rows="3"><%= site.custom_foot_html || '' %></textarea>
248 </label>
249 </fieldset>
250
251 <button type="submit" class="btn btn-primary"><%= isNew ? t('asite.submit_create') : t('asite.submit_save') %></button>
252 </form>
253
254</div>
255
256
257
258<style>
259.admin-site-edit-page { max-width: 760px; margin: 3rem auto; padding: 0 1rem; }
260.admin-site-edit-page h1 { font-family: var(--font-display, serif); font-size: 1.8rem; margin: 0 0 1.5rem; }
261
262.site-form { display: flex; flex-direction: column; gap: 1.5rem; min-width: 0; }
263/* Mobile: a <fieldset> has a browser-default min-inline-size:min-content and
264 therefore does NOT shrink → the entire form became wider than the screen. min-width:0
265 forces it to shrink; form fields get max-width:100% + border-box so they
266 never overflow their fieldset. */
267.site-form fieldset { min-width: 0; }
268.site-form input, .site-form select, .site-form textarea { min-width: 0; max-width: 100%; box-sizing: border-box; }
269.site-form fieldset {
270 border: 1px solid var(--rule);
271 border-radius: 8px;
272 padding: 1rem 1.25rem;
273 background: var(--paper-2);
274 display: flex;
275 flex-direction: column;
276 gap: 0.75rem;
277}
278.site-form fieldset.palette-grid {
279 display: grid;
280 grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
281 background: var(--paper);
282 margin-top: 0.5rem;
283}
284/* Theme default radios — horizontal, sits between accent + palette */
285.site-form fieldset.theme-default {
286 display: flex; flex-direction: row; flex-wrap: wrap; gap: 1rem;
287 background: var(--paper);
288 margin-top: 0.5rem;
289}
290.site-form fieldset.theme-default label.cb { font-size: 0.9rem; color: var(--ink); }
291.site-form fieldset legend { font-family: var(--font-display, serif); font-size: 1rem; padding: 0 0.4rem; }
292.site-form label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.85rem; color: var(--ink-muted, var(--ink-soft)); }
293.site-form label.cb { flex-direction: row; align-items: center; gap: 0.5rem; }
294.site-form input[type="text"], .site-form input[type="color"], .site-form textarea {
295 padding: 0.55rem 0.85rem;
296 border: 1px solid var(--rule);
297 border-radius: 5px;
298 background: var(--paper);
299 color: var(--ink);
300 font-family: inherit;
301 font-size: 0.95rem;
302}
303.site-form input[type="color"] { padding: 0.2rem; height: 38px; }
304.site-form textarea { resize: vertical; min-height: 60px; font-family: var(--font-mono, monospace); font-size: 0.85rem; }
305.site-form input[readonly] { opacity: 0.6; cursor: not-allowed; background: var(--paper-2); }
306
307/* Slug as URL field: dimmed host prefix + the slug itself in the accent colour. */
308.slug-url {
309 display: flex; align-items: stretch;
310 border: 1px solid var(--rule); border-radius: 5px;
311 background: var(--paper); overflow: hidden;
312 transition: border-color 120ms;
313}
314.slug-url:focus-within { border-color: var(--accent); }
315.slug-url-host {
316 display: inline-flex; align-items: center;
317 padding-left: 0.85rem;
318 color: var(--ink-muted, var(--ink-soft));
319 white-space: nowrap; user-select: none;
320 font-size: 0.95rem;
321}
322.site-form .slug-url-input {
323 flex: 1 1 auto; min-width: 0;
324 border: none !important; border-radius: 0 !important;
325 background: transparent !important;
326 color: var(--accent) !important; font-weight: 700;
327 padding: 0.55rem 0.85rem 0.55rem 0.1rem;
328}
329.site-form .slug-url-input:focus { outline: none; }
330.site-form .slug-url-input[readonly] {
331 opacity: 1; cursor: default; background: transparent !important;
332}
333
334/* Selects: same look as the inputs, with a custom chevron instead of the bare
335 native arrow. The open option list is still rendered by the browser. */
336.site-form select {
337 appearance: none;
338 -webkit-appearance: none;
339 padding: 0.55rem 2.2rem 0.55rem 0.85rem;
340 border: 1px solid var(--rule);
341 border-radius: 5px;
342 background-color: var(--paper);
343 color: var(--ink);
344 font-family: inherit;
345 font-size: 0.95rem;
346 cursor: pointer;
347 background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
348 background-repeat: no-repeat;
349 background-position: right 0.7rem center;
350 transition: border-color 0.15s ease, box-shadow 0.15s ease;
351}
352.site-form select:hover { border-color: var(--ink-soft, #888); }
353.site-form select:focus,
354.site-form input[type="text"]:focus,
355.site-form textarea:focus {
356 outline: none;
357 border-color: var(--accent, #c2410c);
358 box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #c2410c) 22%, transparent);
359}
360
361/* Reuse the palette-swatch styles from account.ejs */
362.palette-swatch {
363 display: flex; flex-direction: column; gap: 0.4rem;
364 padding: 0.5rem;
365 border: 1px solid var(--rule); border-radius: 6px; background: var(--paper);
366 cursor: pointer; font-size: 0.85rem; transition: border-color 120ms;
367}
368.palette-swatch:hover { border-color: var(--accent); }
369.palette-swatch input[type="radio"] { position: absolute; opacity: 0; pointer-events: none; }
370.palette-swatch:has(input[type="radio"]:checked) { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
371.palette-preview {
372 display: grid; grid-template-columns: 1fr 1fr;
373 width: 100%; height: 44px; border-radius: 4px; overflow: hidden;
374 border: 1px solid var(--rule);
375}
376.pp-half {
377 position: relative;
378 display: flex; align-items: center; justify-content: center;
379 font-family: var(--font-display, serif); font-weight: 600; font-size: 1rem;
380}
381.pp-letter { line-height: 1; }
382.pp-accent {
383 position: absolute; bottom: 4px; right: 4px;
384 width: 9px; height: 9px; border-radius: 50%;
385 border: 1px solid rgba(0,0,0,0.12);
386}
387.palette-swatch-name { font-weight: 500; color: var(--ink); text-align: center; }
388
389/* Accent picker: row of small colored swatches */
390.site-form fieldset.accent-grid {
391 display: grid;
392 grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
393 gap: 0.5rem;
394 background: var(--paper);
395 margin-top: 0.5rem;
396}
397.accent-swatch {
398 display: flex; flex-direction: column; align-items: center; gap: 0.4rem;
399 padding: 0.55rem 0.4rem;
400 border: 1px solid var(--rule); border-radius: 6px; background: var(--paper);
401 cursor: pointer; font-size: 0.78rem;
402 transition: border-color 120ms, box-shadow 120ms;
403}
404.accent-swatch:hover { border-color: var(--accent); }
405.accent-swatch input[type="radio"] { position: absolute; opacity: 0; pointer-events: none; }
406.accent-swatch:has(input[type="radio"]:checked) {
407 border-color: var(--accent);
408 box-shadow: 0 0 0 2px var(--accent);
409}
410.accent-dot {
411 width: 28px; height: 28px; border-radius: 50%;
412 border: 1px solid rgba(0,0,0,0.1);
413 box-shadow: inset 0 -2px 4px rgba(0,0,0,0.12);
414}
415.accent-swatch-name { color: var(--ink); text-align: center; font-weight: 500; }
416
417.audio-flash { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; }
418.audio-flash--ok { background: rgba(40, 160, 90, 0.15); color: #2a9d5e; border: 1px solid rgba(40, 160, 90, 0.3); }
419.audio-flash--err { background: rgba(200, 60, 60, 0.15); color: #c33; border: 1px solid rgba(200, 60, 60, 0.3); }
420
421.form-hint { font-size: 0.8rem; color: var(--ink-muted, var(--ink-soft)); margin: 0; }
422.form-hint-inline { font-size: 0.78rem; color: var(--ink-muted, var(--ink-soft)); font-weight: 400; margin-left: .25rem; }
423
424/* P63 — Profile photo picker
425 Mobile-first: thumb stacks above input/buttons. ≥520px the thumb floats
426 to the left and the controls fill the remaining width. */
427.photo-picker {
428 display: flex;
429 flex-direction: column;
430 gap: .65rem;
431 align-items: stretch;
432}
433.photo-thumb {
434 position: relative;
435 width: 88px; height: 88px;
436 border-radius: 50%;
437 border: 1px solid var(--rule);
438 background: var(--paper-2);
439 overflow: hidden;
440 flex-shrink: 0;
441 display: flex; align-items: center; justify-content: center;
442}
443.photo-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
444.photo-thumb .photo-empty {
445 display: flex; color: var(--ink-muted, var(--ink-soft)); opacity: .55;
446}
447.photo-thumb .photo-empty svg { width: 36px; height: 36px; }
448.photo-thumb:not([data-empty]) .photo-empty { display: none; }
449.photo-thumb[data-empty] img { display: none; }
450
451.photo-controls {
452 display: flex;
453 flex-direction: column;
454 gap: .5rem;
455 flex: 1;
456 min-width: 0;
457}
458.photo-controls input[type="text"] { width: 100%; }
459.photo-actions {
460 display: flex; align-items: center; flex-wrap: wrap;
461 gap: .4rem;
462}
463.photo-actions .btn { padding: .45rem .75rem; font-size: .85rem; }
464.photo-status { margin-left: .25rem; }
465.photo-status.is-error { color: #c2410c; }
466
467@media (min-width: 520px) {
468 .photo-picker { flex-direction: row; align-items: flex-start; gap: 1rem; }
469 .photo-controls { gap: .55rem; }
470}
471/* Mobile-first: platform selector on its own line (full width), URL input + remove
472 button below. Prevents horizontal overflow on phones (fixed 160px column +
473 non-shrinkable input together ~447px > screen width). */
474.profile-link-row {
475 display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; margin-bottom: 0.5rem;
476}
477.profile-link-row select { padding: 0.45rem 0.6rem; grid-column: 1 / -1; }
478.profile-link-row input { min-width: 0; }
479@media (min-width: 520px) {
480 .profile-link-row { grid-template-columns: 160px 1fr auto; }
481 .profile-link-row select { grid-column: auto; }
482}
483.profile-links-fieldset details > label { margin-top: 0.4rem; }
484</style>
Note: See TracBrowser for help on using the repository browser.