fix(integrationer): en adresse kan læses helt, også på en telefon

Feed'et, mailadressen, krogen, curl-eksemplet, CalDAV-serveren og de to
redirect-URI'er lå i skrivebeskyttede <input>. Et input ombryder ikke — det
ruller indeni — så på en telefon stoppede adressen midt i kassen, og resten
fandtes kun ved at trække inde i feltet. Der var ikke engang et rullepanel at
se: feltet så ud som om det indeholdt det, der stod i det. Målt ved 390 px var
kassen 306 px og krogens adresse 634.

De er nu <output>. Det er stadig et felt, en <label for> kan pege på, så
navnene på felterne bliver ved med at høre sammen med dem, og det er tekst,
der ombryder. `user-select: all` beholder det ene, inputtet gjorde bedre: ét
tryk tager hele adressen.

Det samme gjaldt invitationslinket i en delt note, som vises netop når der
ikke er nogen post at sende det med — altså når det er det eneste, der findes.

Prøven nævner felterne ved navn frem for at forespørge på en klasse. Den, der
måler `.field output` og finder nul, består også den dag felterne er lavet om
til noget andet klippet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kristianwind
2026-09-12 09:18:49 +02:00
co-authored by Claude Opus 5
parent 72637a153a
commit 61f8887319
4 changed files with 80 additions and 20 deletions
+38 -7
View File
@@ -4362,19 +4362,25 @@ test('dagens plan står på I dag, og ikke kun som en besked', async ({ page })
}); });
/** /**
* En kalenderadresse gør ikke siden bredere end telefonen. * Adresserne på integrationssiden kan ses helt på en telefon.
* *
* Adressen på et abonnement er ét ord på et par hundrede tegn uden et mellemrum i, * En URL er ét ord på et par hundrede tegn uden et mellemrum i, og den kan derfor
* og den stod i en liste uden noget, der lod den ombryde. Det, man så, var ikke en * ikke ombryde af sig selv. Det gav to forskellige fejl på den samme side, og
* linje, der løb ud over kanten — det var hele siden, der fik et vandret * prøven her holder øje med dem begge:
* rullepanel, fordi det bredeste element på en side bestemmer sidens bredde. Derfor *
* måler prøven dokumentet og ikke elementet: det er dokumentet, der var galt. * Abonnementets adresse lå i en liste uden noget, der lod den bryde, og det
* bredeste element på en side bestemmer sidens bredde — så hele indholdsspalten fik
* et vandret rullepanel, ikke bare den ene linje.
*
* Feed'et, krogen og CalDAV-serveren lå i skrivebeskyttede <input>, som ruller
* indeni i stedet for at ombryde. Der var ikke noget rullepanel at se: adressen
* stoppede bare midt i kassen, og resten fandtes kun ved at trække inde i feltet.
* *
* Abonnementet kommer fra et svar, prøven selv skriver. Serveren ville skulle hente * Abonnementet kommer fra et svar, prøven selv skriver. Serveren ville skulle hente
* kalenderen for at have et at vise, og en prøve, der går på nettet, er en prøve, * kalenderen for at have et at vise, og en prøve, der går på nettet, er en prøve,
* der fejler en dag af en anden grund end sin egen. * der fejler en dag af en anden grund end sin egen.
*/ */
test('en lang kalenderadresse giver ikke vandret rulning', async ({ page }) => { test('adresserne på integrationssiden kan ses helt på en telefon', async ({ page }) => {
const trouble = watchForTrouble(page); const trouble = watchForTrouble(page);
await page.route('**/api/v1/calendar', (route) => await page.route('**/api/v1/calendar', (route) =>
@@ -4421,5 +4427,30 @@ test('en lang kalenderadresse giver ikke vandret rulning', async ({ page }) => {
expect(spill.content).toBe(0); expect(spill.content).toBe(0);
expect(spill.past).toBeLessThanOrEqual(0); expect(spill.past).toBeLessThanOrEqual(0);
// Og adresserne på siden står der helt. De lå i skrivebeskyttede <input>, som
// ikke kan ombryde: kassen var 306 px og indholdet 595, så resten af adressen
// fandtes kun ved at rulle inde i feltet — hvilket ingen opdager. Et felt, der
// er klippet, er et felt, hvor scrollWidth er større end clientWidth, og det er
// præcis det, der måles her.
//
// Felterne står ved navn og ikke som en forespørgsel på en klasse: en prøve, der
// måler `.field output` og finder nul, består — også den dag felterne er lavet om
// til noget andet klippet. Den skal fejle, hvis et af dem forsvinder.
const clipped = await page.evaluate(() =>
['feed', 'mail', 'hook', 'hookcurl', 'caldav']
.map((id) => {
const el = document.getElementById(id);
if (!el) return `${id}: findes ikke`;
if (el.scrollWidth - el.clientWidth > 1) return `${id}: ${el.scrollWidth} > ${el.clientWidth}`;
return null;
})
.filter(Boolean)
);
expect(clipped).toEqual([]);
// Selve teksten, ikke kun kassens mål: krogens adresse er den, der skal kunne
// læses af, hvis man taster den ind i en genvej på telefonen.
await expect(page.locator('#hook')).toHaveText(/^https?:\/\/.+\/inbound\/hook\/.+/);
expect(trouble).toEqual([]); expect(trouble).toEqual([]);
}); });
+18 -1
View File
@@ -151,7 +151,8 @@
.settings :global(.field input), .settings :global(.field input),
.settings :global(.field select), .settings :global(.field select),
.settings :global(.field textarea) { .settings :global(.field textarea),
.settings :global(.field output) {
width: 100%; width: 100%;
padding: var(--s2) var(--s3); padding: var(--s2) var(--s3);
background: var(--surface-sunken); background: var(--surface-sunken);
@@ -168,6 +169,22 @@
border-color: var(--accent); border-color: var(--accent);
} }
/* Adresserne — feed'et, krogen, CalDAV-serveren — vises i et <output> og ikke i
et skrivebeskyttet <input>. Et input kan ikke ombryde: det ruller indeni, så
på en telefon står halvdelen af adressen uden for kassen, og der er ingen måde
at se resten på. <output> er stadig et felt, en <label for> kan pege på, og det
er tekst, der ombryder.
`user-select: all` er der, fordi det var det eneste, inputtet gjorde bedre: ét
tryk tog hele adressen. Uden den skulle man trække hen over fire linjer. */
.settings :global(.field output) {
display: block;
white-space: pre-wrap;
overflow-wrap: anywhere;
user-select: all;
line-height: 1.5;
}
/* The field error sits under its own input rather than in a summary at the top: /* The field error sits under its own input rather than in a summary at the top:
the server answers with a map of field to message, and putting each one where the server answers with a map of field to message, and putting each one where
it belongs is the whole point of that shape. */ it belongs is the whole point of that shape. */
@@ -460,7 +460,7 @@
it has to match Google's copy exactly, and the error Google gives it has to match Google's copy exactly, and the error Google gives
when it does not names neither value. Google afviser desuden when it does not names neither value. Google afviser desuden
private IP-adresser over http — det skal være https. --> private IP-adresser over http — det skal være https. -->
<input id="redirect" readonly value={client.redirect_uri} /> <output id="redirect">{client.redirect_uri}</output>
</div> </div>
<form onsubmit={saveClient}> <form onsubmit={saveClient}>
@@ -546,7 +546,7 @@
{#if app.user?.is_admin} {#if app.user?.is_admin}
<div class="field"> <div class="field">
<label for="calendar-redirect">{t('int.redirectURI')}</label> <label for="calendar-redirect">{t('int.redirectURI')}</label>
<input id="calendar-redirect" class="mono" readonly value={calendar.redirect_uri} /> <output id="calendar-redirect" class="mono">{calendar.redirect_uri}</output>
<p class="hint">{t('int.calendarSameClient')}</p> <p class="hint">{t('int.calendarSameClient')}</p>
</div> </div>
{:else if !calendar.has_client} {:else if !calendar.has_client}
@@ -653,7 +653,7 @@
<div class="field"> <div class="field">
<label for="feed">{t('int.address')}</label> <label for="feed">{t('int.address')}</label>
<input id="feed" class="mono" value={feedURL} readonly /> <output id="feed" class="mono">{feedURL}</output>
</div> </div>
<div class="row"> <div class="row">
@@ -681,7 +681,7 @@
<div class="field"> <div class="field">
<label for="mail">{t('int.yourAddress')}</label> <label for="mail">{t('int.yourAddress')}</label>
<input id="mail" class="mono" value={mailAddress} readonly /> <output id="mail" class="mono">{mailAddress}</output>
</div> </div>
<div class="row"> <div class="row">
@@ -700,7 +700,7 @@
<div class="field"> <div class="field">
<label for="hook">{t('int.yourHook')}</label> <label for="hook">{t('int.yourHook')}</label>
<input id="hook" class="mono" value={hookURL} readonly /> <output id="hook" class="mono">{hookURL}</output>
<p class="hint"> <p class="hint">
{t('int.hookHow')} {t('int.hookHow')}
</p> </p>
@@ -711,8 +711,8 @@
kroppen bare er den linje, opgaven skal hedde. --> kroppen bare er den linje, opgaven skal hedde. -->
<div class="field"> <div class="field">
<label for="hookcurl">{t('int.hookExample')}</label> <label for="hookcurl">{t('int.hookExample')}</label>
<textarea id="hookcurl" class="mono" rows="3" readonly <output id="hookcurl" class="mono"
>{`curl -X POST ${hookURL} \\\n -H "Content-Type: text/plain" \\\n -d "Ring til Anders i morgen p1 #Firma"`}</textarea >{`curl -X POST ${hookURL} \\\n -H "Content-Type: text/plain" \\\n -d "Ring til Anders i morgen p1 #Firma"`}</output
> >
</div> </div>
@@ -732,7 +732,7 @@
<div class="field"> <div class="field">
<label for="caldav">{t('int.server')}</label> <label for="caldav">{t('int.server')}</label>
<input id="caldav" class="mono" value={`${location.origin}/caldav/`} readonly /> <output id="caldav" class="mono">{`${location.origin}/caldav/`}</output>
<p class="hint"> <p class="hint">
{t('int.caldavAuth')} {t('int.caldavAuth')}
</p> </p>
@@ -817,7 +817,7 @@
color: var(--ink-muted); color: var(--ink-muted);
} }
.setup input[readonly] { .setup output {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--ink-muted); color: var(--ink-muted);
+15 -3
View File
@@ -1279,7 +1279,7 @@
lovet i en besked, der ikke kom af sted. --> lovet i en besked, der ikke kom af sted. -->
<p class="invitelink"> <p class="invitelink">
{t('notes.inviteNoMail')} {t('notes.inviteNoMail')}
<input readonly value={shareInviteLink} onclick={(e) => e.target.select()} /> <output>{shareInviteLink}</output>
</p> </p>
{/if} {/if}
</section> </section>
@@ -2216,11 +2216,23 @@
line-height: 1.5; line-height: 1.5;
} }
.invitelink input { /* <output> og ikke et skrivebeskyttet <input>: et input kan ikke ombryde, så et
invitationslink stod halvt uden for kassen på en telefon, og resten kunne kun
nås ved at rulle inde i feltet. `user-select: all` beholder det, inputtet var
god til — ét tryk tager hele linket. */
.invitelink output {
display: block;
width: 100%; width: 100%;
margin-top: 2px; margin-top: 2px;
font-size: var(--text-xs);
padding: 2px var(--s1); padding: 2px var(--s1);
background: var(--surface-sunken);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--ink);
overflow-wrap: anywhere;
user-select: all;
} }
.addshare { .addshare {