Compare commits

..
Author SHA1 Message Date
kristianwindandClaude Opus 5 4f25905d81 feat(calendar): a task's length is drawn, resizable, and editable
A timed task was drawn at a fixed half hour whatever its `duration_min` said, so a
length the data model already carried was invisible and uneditable. Three parts:

- The calendar draws a task at its real length — a quarter-hour floor so a
  zero-minute task is still grabbable, the day's end as the ceiling — instead of a
  flat thirty minutes. A ninety-minute task now looks like ninety minutes.

- A grip along the foot of a timed task drags its length. Pointer events, not the
  HTML5 drag the body uses, and stopPropagation between them — otherwise making a
  task longer would pick it up and move it instead. It snaps to the quarter hour,
  follows the handle live, and writes once on release through a new store `resize`.
  Moving a task (the existing day/time drag) leaves the length alone.

- The task drawer gains a length field beside the time — minutes, shown only with
  a time to hang it on, since a duration without a start is a length of nothing and
  the calendar has nowhere to draw it. So a length can be set off the calendar too.

e2e: dragging the foot of a task makes it longer both on screen and in what the
server stores, and the time does not move. Full Chromium suite green.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 10:14:39 +02:00
Kristian Wind e3eeb3037c Merge pull request #18 from kristianwind/tidy-note-share-footer
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 7m51s
CI / Docker image builds (push) Successful in 1m37s
CI / Go (push) Failing after 14m7s
Tidy the note sharing UI: gather it behind a Del button
2026-08-24 10:05:08 +02:00
kristianwindandClaude Opus 5 97e6be3afa fix(notes): gather sharing behind a Del button so the footer stays tidy
The sharing controls were laid out across the note's footer — the project select,
the whole "Del med personer" panel and the Save/Delete buttons all on one row. It
crammed: "Læg i projekt" wrapped to two lines and the people panel ran into the
rest.

Now one Del button in the footer opens a popover that holds both ways a note is
shared — a project and named people — stacked in a small card. The footer is a
status on the left and Del / Gem / Slet on the right, and nothing wraps. The
button carries a count when the note is shared with someone, so "shared" is
legible without opening it.

The popover closes on a click outside, on Escape, and on switching to another
note — the last tracked by note id, so a save returning a fresh copy of the same
note does not slam it shut mid-use. The inner "add someone" button is now Tilføj,
so it does not read as a second Del.

e2e updated to open the popover before reaching the controls; full Chromium suite
green.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 09:58:02 +02:00
Kristian Wind d67b61d3c6 Merge pull request #17 from kristianwind/fix-mono-block-escape
CI / Go (push) Failing after 13m12s
CI / OpenAPI is valid (push) Successful in 9s
CI / Docker image builds (push) Successful in 1m52s
Release / Build and publish image (push) Failing after 6s
CI / End-to-end (push) Successful in 6m13s
Fix: let a monospace line be left — on return and through the menu
2026-08-24 09:34:38 +02:00
7 changed files with 389 additions and 86 deletions
+72 -4
View File
@@ -1225,6 +1225,69 @@ test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven ka
* thing this view was before anything was laid over it — and a Google account that
* is not there must not take it down with it.
*/
test('en opgaves længde kan trækkes længere på kalenderen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
// A timed task today at ten, half an hour long. Today, so the week opens on it
// without paging, and ten is inside the hours the grid draws by default.
const today = await page.evaluate(() => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
});
const id = await page.evaluate(async (day) => {
const r = await fetch('/api/v1/tasks', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
body: JSON.stringify({
content: 'Møde med Anders',
due_date: day,
due_time: '10:00',
duration_min: 30
})
});
return (await r.json()).id;
}, today);
await page.goto('/kalender');
await page.getByRole('button', { name: 'Uge', exact: true }).click();
const chip = page
.locator(`[data-date="${today}"] .tevent.task`)
.filter({ hasText: 'Møde med Anders' });
await expect(chip).toBeVisible();
const before = await chip.evaluate((el) => el.getBoundingClientRect().height);
// Drag the foot of the task down a good stretch. It should grow, both on screen
// and in what the server stores — a resize is a change of length, not of time.
const box = await chip.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height - 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2, box.y + box.height + 130, { steps: 6 });
await page.mouse.up();
await expect
.poll(async () =>
page.evaluate(async (tid) => {
const r = await fetch('/api/v1/tasks/' + tid, {
credentials: 'include',
headers: { 'Sec-Fetch-Site': 'same-origin' }
});
return (await r.json()).duration_min;
}, id)
)
.toBeGreaterThan(30);
// And the chip is taller than it was, so the length is something you can see.
await expect.poll(async () => chip.evaluate((el) => el.getBoundingClientRect().height)).toBeGreaterThan(before);
// The time did not move: it is still a ten o'clock task.
await expect(chip.locator('.at')).toHaveText('10:00');
expect(trouble).toEqual([]);
});
test('kalenderen virker uden en forbundet Google-konto', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/kalender');
@@ -2440,7 +2503,8 @@ test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ p
await page.keyboard.type('Fælles aftale om leveringen');
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
// Shared by being filed in a project. Not an access list of its own.
// Filing it in a project — the controls live in the Del popover in the footer.
await page.locator('.sharewrap > button').click();
await page.getByLabel('Læg i projekt').selectOption({ label: 'Delenoter' });
await expect(page.getByText('delt med projektets folk')).toBeVisible();
@@ -2451,6 +2515,7 @@ test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ p
// Taken back out, it is the author's alone again.
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: /Fælles aftale/ }).click();
await page.locator('.sharewrap > button').click();
await page.getByLabel('Læg i projekt').selectOption({ label: 'Intet projekt' });
await expect(page.getByText('din igen')).toBeVisible();
@@ -2487,10 +2552,12 @@ test('en note kan deles med en person direkte, og dukker op hos dem', async ({ b
await page.keyboard.type('Ferieplan for hele holdet');
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
const share = page.locator('.people-share');
// Sharing lives in a popover behind the Del button in the footer.
await page.locator('.sharewrap > button').click();
const share = page.locator('.sharepanel');
await expect(share).toBeVisible();
await share.locator('.addshare select').first().selectOption({ label: 'Sofie' });
await page.getByRole('button', { name: 'Del', exact: true }).click();
await share.getByRole('button', { name: 'Tilføj' }).click();
// She is on the note now, with the role she was given.
const row = share.locator('.sharelist li').filter({ hasText: 'Sofie' });
@@ -2981,7 +3048,8 @@ test('en note kan oprettes inde fra et projekt og hører til det med det samme',
await page.waitForTimeout(1200);
// Den hører til projektet med det samme: "Læg i projekt" står på projektet,
// uden at nogen har valgt det bagefter.
// uden at nogen har valgt det bagefter. Kontrollen ligger i Del-popoveren.
await page.locator('.sharewrap > button').click();
await expect(page.getByLabel('Læg i projekt')).toHaveValue(/.+/);
const shared = await page.getByLabel('Læg i projekt').evaluate((el) => el.selectedOptions[0].textContent);
expect(shared).toContain('Tagprojekt');
+88 -3
View File
@@ -286,6 +286,48 @@
/** The cell lit up under the pointer. */
let over = $state(null);
// A resize in progress: which task, and where its foot is being dragged to, in
// minutes since midnight. Kept apart from the stored length so the task can
// follow the handle live and only be written when the drag ends.
let resizing = $state(null);
/**
* Dragging the foot of a timed task changes how long it is.
*
* Pointer events rather than the HTML5 drag the body uses: the two must not be
* the same gesture, or every attempt to make a task longer would instead pick it
* up and move it. stopPropagation keeps the handle's press from starting the
* move, and the column it lives in is what the minutes are measured against —
* the same box onDropAt reads a drop time from.
*/
function startResize(event, task, date) {
event.stopPropagation();
event.preventDefault();
const col = event.currentTarget.closest('.daycol');
if (!col || !task.due_datetime) return;
const at = new Date(task.due_datetime);
const from = at.getHours() * 60 + at.getMinutes();
resizing = { id: task.id, from, to: from + (task.duration_min || 30), date: iso(date) };
const onMove = (e) => {
const box = col.getBoundingClientRect();
const share = Math.min(Math.max((e.clientY - box.top) / box.height, 0), 1);
const minutes = hours.from * 60 + share * hours.minutes;
// Quarter-hour steps, at least a quarter long, never past midnight.
const snapped = Math.min(Math.max(Math.round(minutes / 15) * 15, from + 15), 24 * 60);
resizing = { ...resizing, to: snapped };
};
const onUp = async () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
const current = resizing;
resizing = null;
if (current) await app.resize(current.id, current.to - current.from);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
}
function onDragOver(event, date) {
if (!carries(event, TASK)) return;
accept(event);
@@ -394,7 +436,16 @@
if (!task.due_datetime) continue;
const at = new Date(task.due_datetime);
const from = at.getHours() * 60 + at.getMinutes();
out.push({ kind: 'task', ref: task, from, to: from + 30 });
// The task's own length, not a fixed half hour. A quarter-hour floor so a
// zero-minute task is still something to see and grab, and the end of the
// day is the ceiling. While a resize is in flight this task follows the
// handle instead of its stored length.
let to =
resizing && resizing.id === task.id
? resizing.to
: from + (task.duration_min || 30);
to = Math.min(Math.max(to, from + 15), 24 * 60);
out.push({ kind: 'task', ref: task, from, to });
}
return out.toSorted((a, b) => a.from - b.from || a.to - b.to);
@@ -547,12 +598,13 @@
</svelte:element>
{/snippet}
{#snippet taskChip(task, kind, extra = '', at = '')}
{#snippet taskChip(task, kind, extra = '', at = '', date = null)}
<!-- Draggable as well as clickable: moving something to another day is the
whole reason to be looking at a calendar. The browser needs a few pixels
of movement before it calls a press a drag, so the click survives. -->
<button
class={kind}
class:resizing={resizing?.id === task.id}
data-priority={task.priority}
draggable="true"
ondragstart={(e) => startDrag(e, TASK, task.id)}
@@ -562,6 +614,16 @@
>
{#if at}<span class="at">{at}</span>{/if}
{task.content}
<!-- Only a task standing at a time can be made longer, and only where the
grid has room to show it — the timed column, which passes the date. -->
{#if date && task.due_datetime}
<span
class="resize"
title={t('cal.resize')}
aria-hidden="true"
onpointerdown={(e) => startResize(e, task, date)}
></span>
{/if}
</button>
{/snippet}
@@ -632,7 +694,7 @@
{#if item.kind === 'event'}
{@render eventChip(item.ref, date, 'tevent', place(item))}
{:else}
{@render taskChip(item.ref, 'tevent task', place(item), clock(item.from))}
{@render taskChip(item.ref, 'tevent task', place(item), clock(item.from), date)}
{/if}
{/each}
</div>
@@ -937,6 +999,29 @@
cursor: grab;
}
/* The grip along the foot of a task, for dragging its length. Out of the way
until the task is hovered or being resized, so it never competes with the
words above it. */
.tevent.task .resize {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 7px;
cursor: ns-resize;
opacity: 0;
touch-action: none;
}
.tevent.task:hover .resize,
.tevent.task.resizing .resize {
opacity: 1;
background: linear-gradient(to bottom, transparent, var(--line-strong));
}
.tevent.task.resizing {
cursor: ns-resize;
z-index: 5;
}
.tevent.task[data-priority='1'] {
border-left-color: var(--p1);
}
+24
View File
@@ -23,6 +23,9 @@
let priority = $state(4);
let dueDate = $state('');
let dueTime = $state('');
// Length in minutes, as a string for the field. Only meaningful with a time —
// see the field below — so it is seeded and shown alongside the clock.
let duration = $state('');
let labels = $state('');
let projectId = $state('');
let recurrence = $state('');
@@ -57,6 +60,7 @@
priority = task.priority;
dueDate = task.due_date ?? '';
dueTime = task.due_datetime ? clockOf(task.due_datetime) : '';
duration = task.duration_min ? String(task.duration_min) : '';
labels = (task.labels ?? []).join(', ');
projectId = task.project_id;
recurrence = task.recurrence_rule ?? '';
@@ -436,6 +440,21 @@
disabled={!dueDate}
onchange={() => save({ due_date: dueDate, due_time: dueTime })}
/>
<!-- Length in minutes, and only with a time to hang it on: a duration
without a start is a length of nothing, and the calendar has
nowhere to draw it. Empty clears it. -->
<input
type="number"
class="dur"
min="0"
step="15"
aria-label={t('detail.duration')}
placeholder={t('detail.durationShort')}
bind:value={duration}
disabled={!dueTime}
onchange={() =>
save({ duration_min: duration === '' ? 0 : Math.max(0, Math.round(Number(duration))) })}
/>
</div>
</div>
</div>
@@ -731,6 +750,11 @@
width: 7.5rem;
}
.due input.dur {
flex: none;
width: 5rem;
}
.due input:disabled {
opacity: 0.5;
}
+5 -1
View File
@@ -25,7 +25,8 @@ export const da = {
'notes.sharedBy': 'Delt af {name}',
'notes.sharePeople': 'Del med personer',
'notes.sharePick': 'Vælg en person …',
'notes.shareAdd': 'Del',
'notes.shareButton': 'Del',
'notes.shareAdd': 'Tilføj',
'notes.shareRoleViewer': 'Kan læse',
'notes.shareRoleEditor': 'Kan rette',
'notes.shareRemove': 'Fjern adgang',
@@ -596,6 +597,8 @@ export const da = {
'detail.none': 'Ingen',
'detail.due': 'Forfalder',
'detail.dueTime': 'Klokkeslæt',
'detail.duration': 'Længde i minutter',
'detail.durationShort': 'min',
'detail.labels': 'Etiketter',
'detail.labelsPlaceholder': 'adskilt af komma',
'detail.project': 'Projekt',
@@ -748,6 +751,7 @@ export const da = {
'cal.settings': 'Indstillinger',
'cal.noneChosen': 'Ingen af kontoens kalendere er valgt endnu.',
'cal.allDay': 'Hele dagen',
'cal.resize': 'Træk for at ændre længden',
'cal.openInGoogle': 'Åbn i Google Kalender',
'cal.beyondWindow':
'Begivenheder gemmes fra {from} til {to}. Uden for det viser gitteret kun dine egne opgaver.',
+5 -1
View File
@@ -21,7 +21,8 @@ export const en = {
'notes.sharedBy': 'Shared by {name}',
'notes.sharePeople': 'Share with people',
'notes.sharePick': 'Choose someone …',
'notes.shareAdd': 'Share',
'notes.shareButton': 'Share',
'notes.shareAdd': 'Add',
'notes.shareRoleViewer': 'Can view',
'notes.shareRoleEditor': 'Can edit',
'notes.shareRemove': 'Remove access',
@@ -592,6 +593,8 @@ export const en = {
'detail.none': 'None',
'detail.due': 'Due',
'detail.dueTime': 'Time',
'detail.duration': 'Length in minutes',
'detail.durationShort': 'min',
'detail.labels': 'Labels',
'detail.labelsPlaceholder': 'separated by commas',
'detail.project': 'Project',
@@ -741,6 +744,7 @@ export const en = {
'cal.settings': 'Settings',
'cal.noneChosen': 'None of the account’s calendars are chosen yet.',
'cal.allDay': 'All day',
'cal.resize': 'Drag to change the length',
'cal.openInGoogle': 'Open in Google Calendar',
'cal.beyondWindow':
'Events are kept from {from} to {to}. Outside that the grid shows only your own tasks.',
+13
View File
@@ -403,6 +403,19 @@ class AppState {
await this.update(id, { due_date: date, due_time: clock });
}
/**
* Sets how long a task is, in minutes. Nothing else about it moves — the date
* and time are left alone, so making a task longer on the calendar does not
* also reschedule it.
*/
async resize(id, minutes) {
const previous = this.get(id);
if (!previous) return;
const rounded = Math.max(15, Math.round(minutes / 15) * 15);
if (previous.duration_min === rounded) return;
await this.update(id, { duration_min: rounded });
}
async remove(id) {
const previous = [...this.tasks];
this.tasks = this.tasks.filter((t) => t.id !== id);
+182 -77
View File
@@ -193,6 +193,26 @@
let shareBusy = $state(false);
const ownsSelected = $derived(!!selected && selected.created_by === app.user?.id);
// The whole of sharing — a project and named people — lives in a popover behind
// one button, so the note's footer stays a status and three buttons rather than
// a row that runs off its own width.
let shareOpen = $state(false);
let shareWrap = $state(null);
// Closing on a switch of note, and only then: the panel belongs to the note it
// was opened on. Tracked by id rather than by the object, so a save that returns
// a fresh copy of the same note does not slam the panel shut mid-use.
let sharePanelFor = $state(null);
$effect(() => {
const id = selected?.id ?? null;
if (id !== sharePanelFor) {
shareOpen = false;
sharePanelFor = id;
}
});
function onShareOutside(event) {
if (shareOpen && shareWrap && !shareWrap.contains(event.target)) shareOpen = false;
}
$effect(() => {
const id = selected?.id;
// Nulstil ved hvert skift, så den forrige notes folk ikke står et øjeblik
@@ -638,6 +658,13 @@
<svelte:head><title>{t('notes.title')} · verdande</title></svelte:head>
<svelte:window
onclick={onShareOutside}
onkeydown={(e) => {
if (e.key === 'Escape' && shareOpen) shareOpen = false;
}}
/>
<div class="notes">
<aside>
<div class="head">
@@ -865,73 +892,105 @@
{/each}
</span>
{/if}
<!-- Filing a note in a project is one way to share it: everybody who can
read the project reads the note. Owner only, because moving a note
the owner is not in would take it away from them. -->
{#if ownsSelected}
<label class="share">
<span class="hint">{t('notes.shareWith')}</span>
<select value={selected.project_id ?? ''} onchange={(e) => share(e.currentTarget.value)}>
<option value="">{t('notes.private')}</option>
{#each app.projects.filter((p) => !p.is_inbox) as project (project.id)}
<option value={project.id}>{project.name}</option>
{/each}
</select>
</label>
<!-- The other way: hand the note to named people, without a project
between. The owner picks who and at what role; the list below is
who can already see it. -->
<div class="people-share">
<span class="hint">{t('notes.sharePeople')}</span>
{#if shares.length}
<ul class="sharelist">
{#each shares as sh (sh.user.id)}
<li>
<span class="who">
<span class="avatar" style="--who: {sh.user.avatar_color}" aria-hidden="true"></span>
{sh.user.name}
</span>
<select
value={sh.role}
onchange={(e) => changeShareRole(sh.user.id, e.currentTarget.value)}
>
<option value="viewer">{t('notes.shareRoleViewer')}</option>
<option value="editor">{t('notes.shareRoleEditor')}</option>
</select>
<button
class="unshare"
onclick={() => removeShare(sh.user.id)}
title={t('notes.shareRemove')}
aria-label={t('notes.shareRemove')}>×</button
>
</li>
{/each}
</ul>
{:else}
<span class="none">{t('notes.shareNobody')}</span>
{/if}
{#if shareCandidates.length}
<div class="addshare">
<select bind:value={sharePick}>
<option value="">{t('notes.sharePick')}</option>
{#each shareCandidates as person (person.id)}
<option value={person.id}>{person.name}</option>
{/each}
</select>
<select bind:value={shareRole}>
<option value="viewer">{t('notes.shareRoleViewer')}</option>
<option value="editor">{t('notes.shareRoleEditor')}</option>
</select>
<button class="button" disabled={!sharePick || shareBusy} onclick={addShare}>
{t('notes.shareAdd')}
</button>
</div>
{/if}
</div>
{/if}
<div class="actions">
<!-- All of sharing behind one button. A note has two ways in — a
project and named people — and both belong to the owner; the
panel gathers them rather than lining them up across the footer. -->
{#if ownsSelected}
<div class="sharewrap" bind:this={shareWrap}>
<button
class="button"
class:on={shareOpen}
aria-expanded={shareOpen}
aria-haspopup="dialog"
onclick={() => (shareOpen = !shareOpen)}
>
{t('notes.shareButton')}{#if shares.length}<span class="count">{shares.length}</span>{/if}
</button>
{#if shareOpen}
<div class="sharepanel" role="dialog" aria-label={t('notes.shareButton')}>
<!-- Filing it in a project: everybody who can read the project
reads the note. A real <label> around the select, so it is
named for a screen reader and not only for the eye. -->
<section>
<label class="field">
<span class="phead">{t('notes.shareWith')}</span>
<select
aria-label={t('notes.shareWith')}
value={selected.project_id ?? ''}
onchange={(e) => share(e.currentTarget.value)}
>
<option value="">{t('notes.private')}</option>
{#each app.projects.filter((p) => !p.is_inbox) as project (project.id)}
<option value={project.id}>{project.name}</option>
{/each}
</select>
</label>
</section>
<!-- Handing it to named people, without a project between. -->
<section>
<span class="phead">{t('notes.sharePeople')}</span>
{#if shares.length}
<ul class="sharelist">
{#each shares as sh (sh.user.id)}
<li>
<span class="who">
<span
class="avatar"
style="--who: {sh.user.avatar_color}"
aria-hidden="true"
></span>
{sh.user.name}
</span>
<select
value={sh.role}
onchange={(e) => changeShareRole(sh.user.id, e.currentTarget.value)}
>
<option value="viewer">{t('notes.shareRoleViewer')}</option>
<option value="editor">{t('notes.shareRoleEditor')}</option>
</select>
<button
class="unshare"
onclick={() => removeShare(sh.user.id)}
title={t('notes.shareRemove')}
aria-label={t('notes.shareRemove')}>×</button
>
</li>
{/each}
</ul>
{:else}
<span class="none">{t('notes.shareNobody')}</span>
{/if}
{#if shareCandidates.length}
<div class="addshare">
<select bind:value={sharePick}>
<option value="">{t('notes.sharePick')}</option>
{#each shareCandidates as person (person.id)}
<option value={person.id}>{person.name}</option>
{/each}
</select>
<select bind:value={shareRole}>
<option value="viewer">{t('notes.shareRoleViewer')}</option>
<option value="editor">{t('notes.shareRoleEditor')}</option>
</select>
<button
class="button"
disabled={!sharePick || shareBusy}
onclick={addShare}
>
{t('notes.shareAdd')}
</button>
</div>
{/if}
</section>
</div>
{/if}
</div>
{/if}
<button class="button" onclick={save}>{t('notes.save')}</button>
{#if ownsSelected}
<button class="button danger" onclick={() => remove(selected)}>{t('notes.delete')}</button>
@@ -1560,13 +1619,58 @@
font-family: var(--mono, ui-monospace, monospace);
}
.share {
/* Sharing lives in a popover above the Del button, so the footer stays a status
and a few buttons rather than a row that runs off its own width. */
.sharewrap {
position: relative;
display: inline-flex;
}
.button.on {
background: var(--surface-raised);
}
.sharewrap .count {
margin-left: 0.4em;
padding: 0 0.45em;
border-radius: var(--radius-full);
background: var(--line-strong);
font-size: 0.85em;
}
.sharepanel {
position: absolute;
bottom: calc(100% + var(--s2));
right: 0;
z-index: 20;
width: 20rem;
max-width: 80vw;
display: flex;
align-items: center;
flex-direction: column;
gap: var(--s3);
padding: var(--s3);
background: var(--surface-raised);
border: 1px solid var(--line-strong);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgb(0 0 0 / 0.18);
text-align: left;
cursor: default;
}
.sharepanel section,
.sharepanel .field {
display: flex;
flex-direction: column;
gap: var(--s1);
}
.share select {
.sharepanel .phead {
font-size: var(--text-xs);
font-weight: 600;
color: var(--ink-muted);
}
.sharepanel select {
font-size: var(--text-xs);
padding: 2px var(--s1);
}
@@ -1597,15 +1701,7 @@
content: none;
}
/* Del med personer, under the note. */
.people-share {
display: flex;
flex-direction: column;
gap: var(--s1);
width: 100%;
}
.people-share .none {
.sharepanel .none {
font-size: var(--text-xs);
color: var(--ink-faint);
}
@@ -1673,6 +1769,15 @@
align-items: center;
gap: var(--s1);
flex-wrap: wrap;
margin-top: var(--s1);
}
/* The person picker takes its own row, so the role and the button below it never
get squeezed to nothing in the panel's width. */
.addshare select:first-child {
flex: 1 1 100%;
}
.addshare .button {
margin-left: auto;
}
.addshare select {
font-size: var(--text-xs);