Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6db61b0cc7 | ||
|
|
57689ddfbb | ||
|
|
7770ba9844 | ||
|
|
55b8e438ad | ||
|
|
1607a22f0c | ||
|
|
8a5dfef41a | ||
|
|
1a610eaac4 | ||
|
|
c2d4ce0179 | ||
|
|
a1b8cc6ddc | ||
|
|
8a2c46f5ea | ||
|
|
8d42f34065 | ||
|
|
905ceebc06 | ||
|
|
87c557941a | ||
|
|
0e1e803486 | ||
|
|
1dc9751a33 | ||
|
|
b81f8f67cc | ||
|
|
f62ffc8d92 | ||
|
|
73ee93deab | ||
|
|
b2fcc0b104 | ||
|
|
8dc601d09a | ||
|
|
85e769a603 | ||
|
|
0363d5f422 | ||
|
|
05b7dac713 | ||
|
|
cfd382e3df | ||
|
|
bbcff40401 | ||
|
|
01001d0980 | ||
|
|
4ac7180cda | ||
|
|
e1683ef09d | ||
|
|
f89fb21502 | ||
|
|
ec147cd1ba |
|
Before Width: | Height: | Size: 249 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 310 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 230 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 334 KiB After Width: | Height: | Size: 334 KiB |
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 266 KiB After Width: | Height: | Size: 284 KiB |
|
Before Width: | Height: | Size: 354 KiB After Width: | Height: | Size: 370 KiB |
|
Before Width: | Height: | Size: 338 KiB After Width: | Height: | Size: 355 KiB |
|
Before Width: | Height: | Size: 230 KiB After Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 254 KiB After Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 227 KiB |
@@ -3,8 +3,10 @@ module github.com/kristianwind/verdande
|
|||||||
go 1.26.3
|
go 1.26.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc
|
||||||
github.com/coder/websocket v1.8.15
|
github.com/coder/websocket v1.8.15
|
||||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
||||||
|
github.com/emersion/go-message v0.18.2
|
||||||
github.com/go-chi/chi/v5 v5.3.1
|
github.com/go-chi/chi/v5 v5.3.1
|
||||||
github.com/go-webauthn/webauthn v0.17.4
|
github.com/go-webauthn/webauthn v0.17.4
|
||||||
github.com/pquerna/otp v1.5.0
|
github.com/pquerna/otp v1.5.0
|
||||||
@@ -14,9 +16,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/emersion/go-message v0.18.2 // indirect
|
|
||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/boombuler/barcode/qr"
|
||||||
"github.com/pquerna/otp"
|
"github.com/pquerna/otp"
|
||||||
"github.com/pquerna/otp/totp"
|
"github.com/pquerna/otp/totp"
|
||||||
)
|
)
|
||||||
@@ -50,6 +51,41 @@ func NewTOTPSecret(issuer, account string) (secret, uri string, err error) {
|
|||||||
return key.Secret(), key.URL(), nil
|
return key.Secret(), key.URL(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TOTPQRSVG renders the otpauth:// URI as a QR code, as an SVG a phone camera can
|
||||||
|
// read. SVG rather than a PNG data URI so it stays crisp at any size and adds no
|
||||||
|
// base64 blob to the page — one dark path on a white square, which is what a scanner
|
||||||
|
// needs whatever theme the page is in.
|
||||||
|
//
|
||||||
|
// The barcode encoder is already in the module graph (pquerna/otp draws on it), so
|
||||||
|
// this borrows a dependency rather than adding one.
|
||||||
|
func TOTPQRSVG(uri string) (string, error) {
|
||||||
|
code, err := qr.Encode(uri, qr.M, qr.Auto)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("auth: encode totp qr: %w", err)
|
||||||
|
}
|
||||||
|
b := code.Bounds()
|
||||||
|
n := b.Dx()
|
||||||
|
// A four-module quiet zone all round, which the spec requires and scanners rely
|
||||||
|
// on — without it the code runs to the edge and a reader cannot find its border.
|
||||||
|
const quiet = 4
|
||||||
|
size := n + quiet*2
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
fmt.Fprintf(&sb, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" shape-rendering="crispEdges">`, size, size)
|
||||||
|
sb.WriteString(`<rect width="100%" height="100%" fill="#ffffff"/><path fill="#111312" d="`)
|
||||||
|
for y := 0; y < n; y++ {
|
||||||
|
for x := 0; x < n; x++ {
|
||||||
|
// Dark modules are black; the red channel is 0 for those and full for the
|
||||||
|
// light ones. One 1×1 rect per dark module, merged into a single path.
|
||||||
|
if r, _, _, _ := code.At(b.Min.X+x, b.Min.Y+y).RGBA(); r == 0 {
|
||||||
|
fmt.Fprintf(&sb, "M%d %dh1v1h-1z", x+quiet, y+quiet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString(`"/></svg>`)
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
// TOTPURI rebuilds the otpauth:// URI for an existing secret, for showing the QR
|
// TOTPURI rebuilds the otpauth:// URI for an existing secret, for showing the QR
|
||||||
// code again before enrolment is confirmed.
|
// code again before enrolment is confirmed.
|
||||||
func TOTPURI(secret, issuer, account string) string {
|
func TOTPURI(secret, issuer, account string) string {
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import (
|
|||||||
type totpSetupResponse struct {
|
type totpSetupResponse struct {
|
||||||
Secret string `json:"secret"`
|
Secret string `json:"secret"`
|
||||||
URI string `json:"uri"`
|
URI string `json:"uri"`
|
||||||
|
// The URI as a scannable QR code (SVG). The secret and URI stay for typing in by
|
||||||
|
// hand on a device that cannot scan.
|
||||||
|
QR string `json:"qr"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -54,7 +57,12 @@ func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "store totp secret", err)
|
s.internal(w, r, "store totp secret", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri})
|
qrSVG, err := auth.TOTPQRSVG(uri)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "render totp qr", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri, QR: qrSVG})
|
||||||
}
|
}
|
||||||
|
|
||||||
type totpConfirmResponse struct {
|
type totpConfirmResponse struct {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
@@ -298,11 +298,65 @@ test('manifestet og service workeren findes, og ikonerne findes også', async ({
|
|||||||
const appleIcon = await request.get('/apple-touch-icon.png');
|
const appleIcon = await request.get('/apple-touch-icon.png');
|
||||||
expect(appleIcon.ok(), 'apple-touch-icon.png findes ikke').toBeTruthy();
|
expect(appleIcon.ok(), 'apple-touch-icon.png findes ikke').toBeTruthy();
|
||||||
|
|
||||||
// The service worker is what Web Push needs; without it a subscription cannot
|
// The service worker is what Web Push needs — and, since it also caches, what
|
||||||
// be created at all.
|
// carries the app through a short network drop. SvelteKit serves it under this
|
||||||
const worker = await request.get('/sw.js');
|
// name and registers it itself; the old hand-registered /sw.js is gone.
|
||||||
|
const worker = await request.get('/service-worker.js');
|
||||||
expect(worker.ok()).toBeTruthy();
|
expect(worker.ok()).toBeTruthy();
|
||||||
expect(await worker.text()).toContain("addEventListener('push'");
|
// The source is minified, so the quotes around the event names are whatever the
|
||||||
|
// minifier chose — match either kind rather than one it might change under us.
|
||||||
|
const source = await worker.text();
|
||||||
|
expect(source).toMatch(/addEventListener\(['"`]push['"`]/);
|
||||||
|
expect(source).toMatch(/addEventListener\(['"`]fetch['"`]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app survives a network drop: reloaded with the network cut, it opens from
|
||||||
|
* the cache and still shows the tasks it last saw, instead of the browser's own
|
||||||
|
* "you are offline" page.
|
||||||
|
*
|
||||||
|
* This is the whole point of the service worker's cache — not offline authoring,
|
||||||
|
* which the app does not claim, but a reload through a tunnel or a handover coming
|
||||||
|
* back to the app rather than to a dead tab. The retry in api.js covers the write
|
||||||
|
* side of the same drop; this covers the read side.
|
||||||
|
*/
|
||||||
|
test.describe('med service worker', () => {
|
||||||
|
// The rest of the suite blocks the worker (see playwright.config.js); this one
|
||||||
|
// test is about the worker, so it turns it back on for its own context.
|
||||||
|
test.use({ serviceWorkers: 'allow' });
|
||||||
|
|
||||||
|
test('appen overlever et netværksudfald og åbner fra cachen', async ({ page, context }) => {
|
||||||
|
// First load, online: the worker installs, and once it controls the page the
|
||||||
|
// shell and the reads it serves are in the cache.
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible();
|
||||||
|
|
||||||
|
// Wait until the worker actually controls this page. Until it does, a reload
|
||||||
|
// still goes to the network and the test proves nothing — the first load
|
||||||
|
// registers the worker but the page that triggered the install is not itself
|
||||||
|
// controlled until it activates and claims.
|
||||||
|
await page.waitForFunction(() => navigator.serviceWorker?.controller != null, null, {
|
||||||
|
timeout: 15000
|
||||||
|
});
|
||||||
|
|
||||||
|
// A controlled reload, still online: now the navigation and its API reads pass
|
||||||
|
// through the worker and land in the cache, ready for the drop.
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible();
|
||||||
|
|
||||||
|
// The network is gone.
|
||||||
|
await context.setOffline(true);
|
||||||
|
try {
|
||||||
|
await page.reload();
|
||||||
|
// The shell came from the cache — the sidebar is here, not a browser error
|
||||||
|
// page — and so did the task list the app renders into it.
|
||||||
|
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible({ timeout: 15000 });
|
||||||
|
await expect(page.getByRole('link', { name: 'Indbakke' })).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
// Never leave the shared context offline for whatever test runs next.
|
||||||
|
await context.setOffline(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -371,9 +425,9 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
await sidebar.getByLabel('Gruppens navn').fill('Arbejde');
|
await sidebar.getByLabel('Gruppens navn').fill('Arbejde');
|
||||||
await sidebar.getByLabel('Gruppens navn').press('Enter');
|
await sidebar.getByLabel('Gruppens navn').press('Enter');
|
||||||
|
|
||||||
// The name is a link to the group's own page; the chevron beside it is a
|
// The name is a link to the group's own page; the folder icon beside it is a
|
||||||
// separate button that folds. Two intentions, two targets — the single one they
|
// separate button that folds. Two intentions, two targets — the single one they
|
||||||
// used to share could only ever serve the smaller.
|
// used to share could only ever serve the smaller, and neither wears an arrow.
|
||||||
//
|
//
|
||||||
// `exact` on the link, because a group's name is a user-chosen word that can
|
// `exact` on the link, because a group's name is a user-chosen word that can
|
||||||
// turn up inside another control's name, and Playwright matches an accessible
|
// turn up inside another control's name, and Playwright matches an accessible
|
||||||
@@ -389,13 +443,14 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
// The clipping half is here because it was once broken: "Omdøb" and "Slet" sat
|
// The clipping half is here because it was once broken: "Omdøb" and "Slet" sat
|
||||||
// in the flow reserving their width even while invisible, so ARBEJDE was drawn
|
// in the flow reserving their width even while invisible, so ARBEJDE was drawn
|
||||||
// as "ARBE". The alignment half changed meaning with the redesign: a group now
|
// as "ARBE". The alignment half changed meaning with the redesign: a group now
|
||||||
// carries a fold chevron and a section icon just like "Projekter" above it, and
|
// carries a section icon just like "Projekter" above it, and belongs to that
|
||||||
// belongs to that heading tier rather than to the project rows below.
|
// heading tier rather than to the project rows below.
|
||||||
const shape = await sidebar.locator('.folder-head').evaluate((el) => {
|
const shape = await sidebar.locator('.folder-head').evaluate((el) => {
|
||||||
const link = el.querySelector('h2 a');
|
const link = el.querySelector('h2 a');
|
||||||
// The leading mark is now the section icon, the same folder/funnel/tag glyph
|
// The leading mark is the section icon in its own fold button, the same
|
||||||
// the system headings carry — a group and a section read identically.
|
// folder/funnel/tag glyph the system headings carry — a group and a section
|
||||||
const name = { ...link.querySelector('.head-icon').getBoundingClientRect().toJSON() };
|
// read identically, and the icon sits before the name, not inside the link.
|
||||||
|
const name = { ...el.querySelector('.head-icon').getBoundingClientRect().toJSON() };
|
||||||
return {
|
return {
|
||||||
clipped: link.scrollWidth > link.clientWidth + 1,
|
clipped: link.scrollWidth > link.clientWidth + 1,
|
||||||
left: name.left,
|
left: name.left,
|
||||||
@@ -408,7 +463,7 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
expect(shape.count, 'navnet og tallet overlapper').toBeGreaterThanOrEqual(shape.right);
|
expect(shape.count, 'navnet og tallet overlapper').toBeGreaterThanOrEqual(shape.right);
|
||||||
|
|
||||||
// A group is a heading, not a project row: it belongs to the same tier as
|
// A group is a heading, not a project row: it belongs to the same tier as
|
||||||
// "Projekter" and the other system sections — chevron, icon, bold name — and
|
// "Projekter" and the other system sections — icon, bold name, no arrow — and
|
||||||
// starts where they start, not where the projects filed under it do. Measured
|
// starts where they start, not where the projects filed under it do. Measured
|
||||||
// icon to icon, the one mark both headings share.
|
// icon to icon, the one mark both headings share.
|
||||||
const sectionLeft = await sidebar
|
const sectionLeft = await sidebar
|
||||||
@@ -440,13 +495,13 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
'false'
|
'false'
|
||||||
);
|
);
|
||||||
|
|
||||||
// A group's chevron is in the flow now, the same one the system sections carry —
|
// The fold control is the bare folder icon now — no chevron, and no --surface
|
||||||
// no absolute mark with a --surface field behind it, so a folded group no longer
|
// field behind it, so a folded group leaves no pale square beside its name on
|
||||||
// leaves a pale square beside its name on the sunken bar.
|
// the sunken bar.
|
||||||
const chevronBg = await sidebar
|
const foldBg = await sidebar
|
||||||
.locator('.folder-head .chevron-button')
|
.locator('.folder-head .fold-icon')
|
||||||
.evaluate((el) => getComputedStyle(el).backgroundColor);
|
.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||||
expect(chevronBg, 'chevronen har en firkant bag sig').toBe('rgba(0, 0, 0, 0)');
|
expect(foldBg, 'foldeknappen har en firkant bag sig').toBe('rgba(0, 0, 0, 0)');
|
||||||
|
|
||||||
await sidebar.getByRole('button', { name: /^Fold Arbejde/ }).click();
|
await sidebar.getByRole('button', { name: /^Fold Arbejde/ }).click();
|
||||||
|
|
||||||
@@ -564,8 +619,13 @@ test('en opgave kan trækkes ned i en tom sektion, og en fejlet indlæsning sige
|
|||||||
await expect(section.getByText('brænde kaffe')).toBeVisible();
|
await expect(section.getByText('brænde kaffe')).toBeVisible();
|
||||||
await expect(page.locator('.toast')).toHaveCount(0);
|
await expect(page.locator('.toast')).toHaveCount(0);
|
||||||
|
|
||||||
// A load that fails says so, and offers to try again.
|
// A load that fails with a server error says so at once and offers to try again.
|
||||||
await page.route('**/api/v1/projects/*', (r) => r.abort());
|
// A 500 is the server answering, not a dropped connection, so it is not a blip to
|
||||||
|
// wait out — it is not retried, and the message is immediate. (A dropped
|
||||||
|
// connection now retries for a few seconds first; that is the self-heal test.)
|
||||||
|
await page.route('**/api/v1/projects/*', (r) =>
|
||||||
|
r.fulfill({ status: 500, contentType: 'application/json', body: '{"code":"internal_error"}' })
|
||||||
|
);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.getByText('Serveren svarede ikke')).toBeVisible();
|
await expect(page.getByText('Serveren svarede ikke')).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'Prøv igen' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Prøv igen' })).toBeVisible();
|
||||||
@@ -576,6 +636,52 @@ test('en opgave kan trækkes ned i en tom sektion, og en fejlet indlæsning sige
|
|||||||
expect(trouble.filter((t) => !t.includes('projects'))).toEqual([]);
|
expect(trouble.filter((t) => !t.includes('projects'))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A save made during a short drop is not lost to it.
|
||||||
|
*
|
||||||
|
* This is the whole of what "offline" means here: not a cache to work in with no
|
||||||
|
* server, but a save that rides out a few seconds of no connection and goes through
|
||||||
|
* when it comes back — with a quiet "reconnecting" while it waits, and no error and
|
||||||
|
* no reverted change once it heals. A drop that outlasts the retry window is a real
|
||||||
|
* outage and takes the normal error path; that is not this test.
|
||||||
|
*/
|
||||||
|
test('en kort netaudsætning under en handling genoprettes af sig selv', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Wait for the app to finish loading before cutting the line — `goto` resolves at
|
||||||
|
// the load event, but the data load runs after hydration, and aborting that would
|
||||||
|
// test a cold start, not a blip mid-use.
|
||||||
|
const box = page.getByLabel('Ny opgave');
|
||||||
|
await expect(box).toBeVisible();
|
||||||
|
|
||||||
|
// The app is loaded and online. Now the connection drops — every call fails, the
|
||||||
|
// way it does in a tunnel, rather than one endpoint erroring.
|
||||||
|
let down = true;
|
||||||
|
await page.route('**/api/v1/**', (route) => (down ? route.abort() : route.continue()));
|
||||||
|
|
||||||
|
// A task captured during the drop. Its save fails on the network and starts
|
||||||
|
// retrying rather than giving up.
|
||||||
|
await box.fill('under et udfald');
|
||||||
|
await box.press('Enter');
|
||||||
|
|
||||||
|
// The quiet pill, once the retry has lasted long enough to be worth showing.
|
||||||
|
await expect(page.locator('.netstatus')).toBeVisible();
|
||||||
|
|
||||||
|
// The drop ends. The retry goes through on its own and the pill leaves — no
|
||||||
|
// error, nothing toasted as failed.
|
||||||
|
down = false;
|
||||||
|
await expect(page.locator('.netstatus')).toHaveCount(0, { timeout: 10000 });
|
||||||
|
await expect(page.locator('.toast')).toHaveCount(0);
|
||||||
|
|
||||||
|
// And the save landed: an undated quick capture goes to the Inbox, and there it
|
||||||
|
// is, saved across the drop rather than lost to it.
|
||||||
|
await page.getByRole('link', { name: /Indbakke/ }).click();
|
||||||
|
await expect(page.getByText('under et udfald')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A task can be dragged onto another day, and onto another project.
|
* A task can be dragged onto another day, and onto another project.
|
||||||
*
|
*
|
||||||
@@ -1978,7 +2084,12 @@ test('en passkey kan registreres og logges ind med', async ({ browser, browserNa
|
|||||||
// Now sign out and back in with it. No email typed: the device knows which
|
// Now sign out and back in with it. No email typed: the device knows which
|
||||||
// account its key belongs to, which is also why this page cannot be used to
|
// account its key belongs to, which is also why this page cannot be used to
|
||||||
// find out who has an account here.
|
// find out who has an account here.
|
||||||
await page.getByRole('navigation', { name: 'Hovedmenu' }).getByText('Log ud').click();
|
// Signing out asks first now: one click arms it, a second within a few seconds
|
||||||
|
// does it. The whole account row is the button; its label changes to the prompt.
|
||||||
|
const signOut = page.getByRole('navigation', { name: 'Hovedmenu' }).locator('.user');
|
||||||
|
await signOut.click();
|
||||||
|
await expect(signOut).toContainText('Tryk igen for at logge ud');
|
||||||
|
await signOut.click();
|
||||||
// `exact`, because "Log ind" is a substring of "Log ind med en passkey" and
|
// `exact`, because "Log ind" is a substring of "Log ind med en passkey" and
|
||||||
// Playwright matches an accessible name loosely unless told otherwise.
|
// Playwright matches an accessible name loosely unless told otherwise.
|
||||||
await expect(page.getByRole('button', { name: 'Log ind', exact: true })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Log ind', exact: true })).toBeVisible();
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ export default defineConfig({
|
|||||||
|
|
||||||
use: {
|
use: {
|
||||||
baseURL,
|
baseURL,
|
||||||
|
// Off by default. The app now ships a caching service worker, and once it
|
||||||
|
// controls a page Playwright's page.route() no longer sees the fetches it
|
||||||
|
// makes — so a test that forces a 500 to check the error path gets the real
|
||||||
|
// 200 through the worker instead, and reads as a broken feature. The one test
|
||||||
|
// that needs the real worker turns it back on for its own context.
|
||||||
|
serviceWorkers: 'block',
|
||||||
trace: 'retain-on-failure',
|
trace: 'retain-on-failure',
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
// Pinned, because the app reads both. The sign-up form sends
|
// Pinned, because the app reads both. The sign-up form sends
|
||||||
|
|||||||
@@ -210,6 +210,52 @@
|
|||||||
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.4);
|
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Kul — the greys of Apple Notes' dark mode, to Kristian's own sampled values.
|
||||||
|
Three panel greys, lightest sidebar to darkest editor — the reverse of the usual
|
||||||
|
sunken-is-darkest order, which is why the editor gets its own --editor-bg rather
|
||||||
|
than sharing --surface-raised with the list. The accent turns amber, and the
|
||||||
|
selected menu item, selected note and section headings each get a fallback token
|
||||||
|
so this theme can hit its colours without touching any other. */
|
||||||
|
[data-theme='charcoal'] {
|
||||||
|
--ground: #1e1e1e;
|
||||||
|
--surface: #2a3033;
|
||||||
|
--surface-raised: #242b2e; /* the note list */
|
||||||
|
--surface-sunken: #2d3133; /* the menu / sidebar — lightest here */
|
||||||
|
--line: #34393b;
|
||||||
|
--line-strong: #414749;
|
||||||
|
|
||||||
|
--ink: #ffffff;
|
||||||
|
--ink-muted: #dcdcdc;
|
||||||
|
--ink-faint: #9ba0a2;
|
||||||
|
|
||||||
|
/* Amber, Apple's Notes accent. The bright value is the selected menu item; the
|
||||||
|
dark text rides on top of it. */
|
||||||
|
--accent: #ffce35;
|
||||||
|
--accent-hover: #ffd75a;
|
||||||
|
--accent-sunken: #ffce35;
|
||||||
|
--accent-ink: #1e1e1e;
|
||||||
|
|
||||||
|
/* Per-element, and only here — every other theme falls back to the shared token
|
||||||
|
in the var() calls that read these. */
|
||||||
|
--editor-bg: #1e1e1e; /* the text field, a shade below the list */
|
||||||
|
--note-on-bg: #a78c26; /* a selected note in the list — gold */
|
||||||
|
--note-on-ink: #1e1e1e; /* dark text on the gold */
|
||||||
|
--nav-active-ink: #1e1e1e; /* dark text on the amber menu item */
|
||||||
|
--heading-ink: #8a8e90; /* PROJEKTER and the group names — quiet grey */
|
||||||
|
--note-group-bg: #2d3133; /* day separators take the menu's resting grey, not the darker ground */
|
||||||
|
|
||||||
|
--p1: #cf6b6b;
|
||||||
|
--p2: #d69f5c;
|
||||||
|
--p3: #6b9ecf;
|
||||||
|
--p4: var(--ink-faint);
|
||||||
|
|
||||||
|
--danger: #cf6b6b;
|
||||||
|
--danger-sunken: #33201f;
|
||||||
|
|
||||||
|
--shadow: 0 1px 2px rgb(0 0 0 / 0.25);
|
||||||
|
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
/* Papir — warm light, closer to paper than to a screen. */
|
/* Papir — warm light, closer to paper than to a screen. */
|
||||||
[data-theme='paper'] {
|
[data-theme='paper'] {
|
||||||
--ground: #f6f1e7;
|
--ground: #f6f1e7;
|
||||||
@@ -375,9 +421,13 @@ button {
|
|||||||
søgefelt. */
|
søgefelt. */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A translucent wash of the accent, not a solid block and not the old near-invisible
|
||||||
|
tint. --accent-sunken sat a hair off the editor's own surface in dark, so a
|
||||||
|
selection could not be seen; a solid accent could, but read as a heavy slab. Mixing
|
||||||
|
the accent part-way into transparent lays a clear green film over the text while it
|
||||||
|
keeps its own colour — visible on white and on the dark surfaces alike. */
|
||||||
::selection {
|
::selection {
|
||||||
background: var(--accent-sunken);
|
background: color-mix(in oklab, var(--accent) 42%, transparent);
|
||||||
color: var(--ink);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbars, made quiet. The default on Windows is a wide grey slab that
|
/* Scrollbars, made quiet. The default on Windows is a wide grey slab that
|
||||||
|
|||||||
@@ -23,6 +23,57 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
import { t } from './i18n.svelte.js';
|
import { t } from './i18n.svelte.js';
|
||||||
|
import { connectivity, sleepOrOnline } from './connectivity.svelte.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a request keeps retrying a network failure before giving up.
|
||||||
|
*
|
||||||
|
* This is the whole of the "offline" feature and its whole limit: it rides out a
|
||||||
|
* blip of a few seconds so a save is not lost to it, and then — because the app is
|
||||||
|
* a live view of a shared list, not a local database — it stops and says so rather
|
||||||
|
* than holding an edit for a network that is not coming back. Fifteen seconds is
|
||||||
|
* long enough for a handover or a tunnel and short enough that a real outage fails
|
||||||
|
* while the user is still looking at what they did.
|
||||||
|
*/
|
||||||
|
const RETRY_WINDOW_MS = 15000;
|
||||||
|
|
||||||
|
/** Backoff between attempts: 0.5s, 1s, 2s, then 3s, capped. A waiting sleep also
|
||||||
|
* wakes the instant the OS reports a link again, so most blips end on the first. */
|
||||||
|
const backoff = (attempt) => Math.min(500 * 2 ** attempt, 3000);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs `send` (a fetch), retrying only network failures for a short window.
|
||||||
|
*
|
||||||
|
* A network failure is fetch rejecting — no HTTP status ever arrived. An HTTP
|
||||||
|
* error (4xx/5xx) is the server answering, so it is returned, not retried: retrying
|
||||||
|
* a 400 just asks the same wrong question again. An abort is the caller changing
|
||||||
|
* its mind (a superseded search), so it is left to propagate.
|
||||||
|
*
|
||||||
|
* Retrying is not free of risk: a create whose request reached the server but whose
|
||||||
|
* response was lost would be made twice. For a drop of seconds that is rare, and the
|
||||||
|
* alternative — losing the save every time the network hiccups — is the thing this
|
||||||
|
* exists to stop. A server-side idempotency key would remove even that; it is a
|
||||||
|
* larger change noted for later.
|
||||||
|
*/
|
||||||
|
async function withRetry(send, noRetry = false) {
|
||||||
|
const deadline = Date.now() + (noRetry ? 0 : RETRY_WINDOW_MS);
|
||||||
|
let attempt = 0;
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
const res = await send();
|
||||||
|
connectivity.reachable();
|
||||||
|
return res;
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.name === 'AbortError') throw e;
|
||||||
|
if (Date.now() >= deadline) {
|
||||||
|
connectivity.lost();
|
||||||
|
throw new ApiError(0, 'offline', 'ingen forbindelse');
|
||||||
|
}
|
||||||
|
connectivity.retrying();
|
||||||
|
await sleepOrOnline(backoff(attempt++));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The key for each code the API can return.
|
* The key for each code the API can return.
|
||||||
@@ -110,14 +161,10 @@ async function request(method, path, body, options = {}) {
|
|||||||
init.body = JSON.stringify(body);
|
init.body = JSON.stringify(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
let res;
|
// Retries a network failure for a few seconds so a short drop does not lose the
|
||||||
try {
|
// call; `withRetry` turns an exhausted window into the same `offline` error the
|
||||||
res = await fetch(`/api/v1${path}`, init);
|
// app already handles. `options.noRetry` opts a call out — used by nothing yet.
|
||||||
} catch (e) {
|
const res = await withRetry(() => fetch(`/api/v1${path}`, init), options.noRetry);
|
||||||
// A network failure is not an HTTP status; give it one the app can handle
|
|
||||||
// the same way as everything else.
|
|
||||||
throw new ApiError(0, 'offline', 'ingen forbindelse');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.status === 204) return null;
|
if (res.status === 204) return null;
|
||||||
|
|
||||||
@@ -157,12 +204,9 @@ async function upload(path, file, field = 'file') {
|
|||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append(field, file);
|
form.append(field, file);
|
||||||
|
|
||||||
let res;
|
const res = await withRetry(() =>
|
||||||
try {
|
fetch(`/api/v1${path}`, { method: 'POST', body: form, credentials: 'same-origin' })
|
||||||
res = await fetch(`/api/v1${path}`, { method: 'POST', body: form, credentials: 'same-origin' });
|
);
|
||||||
} catch {
|
|
||||||
throw new ApiError(0, 'offline', 'ingen forbindelse');
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
let payload = null;
|
let payload = null;
|
||||||
|
|||||||
@@ -31,9 +31,36 @@
|
|||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
await api.logout();
|
await api.logout();
|
||||||
|
// Empty the offline data cache before leaving: the service worker keeps the
|
||||||
|
// last-seen task and note lists so a network drop does not blank the app, and
|
||||||
|
// the next person to sign in on this browser must not find them.
|
||||||
|
try {
|
||||||
|
navigator.serviceWorker?.controller?.postMessage({ type: 'clear-api-cache' });
|
||||||
|
await caches?.delete('verdande-api');
|
||||||
|
} catch {
|
||||||
|
// No service worker / no Cache API (older browser, private mode): nothing
|
||||||
|
// was cached, so nothing to clear.
|
||||||
|
}
|
||||||
location.href = '/';
|
location.href = '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Signing out is one click from a button the pointer passes over on its way to
|
||||||
|
// everything below it, and it drops you at the login screen — so it asks first.
|
||||||
|
// A second click within a few seconds confirms; anything else lets it lapse. Not
|
||||||
|
// a dialog: a two-step button is lighter and stays where the eye already is.
|
||||||
|
let confirmSignOut = $state(false);
|
||||||
|
let signOutTimer;
|
||||||
|
function onSignOutClick() {
|
||||||
|
if (confirmSignOut) {
|
||||||
|
clearTimeout(signOutTimer);
|
||||||
|
signOut();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
confirmSignOut = true;
|
||||||
|
clearTimeout(signOutTimer);
|
||||||
|
signOutTimer = setTimeout(() => (confirmSignOut = false), 3500);
|
||||||
|
}
|
||||||
|
|
||||||
let filters = $state([]);
|
let filters = $state([]);
|
||||||
let labels = $state([]);
|
let labels = $state([]);
|
||||||
|
|
||||||
@@ -682,13 +709,14 @@
|
|||||||
<!--
|
<!--
|
||||||
A fixed heading that folds, the same way a project group's does.
|
A fixed heading that folds, the same way a project group's does.
|
||||||
|
|
||||||
One snippet for all four rather than four copies of a chevron and an aria
|
One snippet for all four rather than four copies of an icon and an aria
|
||||||
attribute: they have to behave identically, and the group heading is already
|
attribute: they have to behave identically, and the group heading is already
|
||||||
the proof that they drift otherwise — it grew a fold, a colour and two
|
the proof that they drift otherwise — it grew a fold, a colour and two
|
||||||
actions while these stayed plain text.
|
actions while these stayed plain text.
|
||||||
|
|
||||||
The whole heading is the target, not the chevron. At this size a lone chevron
|
The whole heading is the target, and the icon is the mark: no arrow beside it.
|
||||||
is something you miss, and the name is where the eye already is.
|
At this size an extra glyph is clutter, and the name is where the eye already
|
||||||
|
is.
|
||||||
-->
|
-->
|
||||||
<!-- One monochrome mark per kind of heading, so a section and a group read as the
|
<!-- One monochrome mark per kind of heading, so a section and a group read as the
|
||||||
same kind of thing — a folder of projects, a funnel of filters, a tag of
|
same kind of thing — a folder of projects, a funnel of filters, a tag of
|
||||||
@@ -749,19 +777,15 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet foldHeading(key, label)}
|
{#snippet foldHeading(key, label)}
|
||||||
|
<!-- No chevron: a system section has no page and nothing to rename, so the
|
||||||
|
whole heading is one button that folds it. The icon and the bold name are
|
||||||
|
the whole control — an arrow beside them was only ever saying "clickable"
|
||||||
|
twice. -->
|
||||||
<h2 class="fold">
|
<h2 class="fold">
|
||||||
<button
|
<button
|
||||||
onclick={() => app.toggleSection(key)}
|
onclick={() => app.toggleSection(key)}
|
||||||
aria-expanded={!app.sectionCollapsed(key)}
|
aria-expanded={!app.sectionCollapsed(key)}
|
||||||
>
|
>
|
||||||
<svg
|
|
||||||
class="chevron"
|
|
||||||
class:collapsed={app.sectionCollapsed(key)}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M6 9l6 6 6-6" />
|
|
||||||
</svg>
|
|
||||||
{@render sectionIcon(key)}
|
{@render sectionIcon(key)}
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
@@ -930,31 +954,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- The button sits inside the heading, not around it, the same way a
|
<!-- No chevron: the folder icon itself folds the group, and the name
|
||||||
project's title does: a group *is* a heading in the sidebar, and
|
beside it opens the group's own page. Two intentions, two targets,
|
||||||
wrapping an h2 in a button both invalidates the markup and takes
|
but no arrow to clutter the rail — the icon a section carries is
|
||||||
the heading away from a screen reader. The whole heading folds —
|
the same one that folds it here. The button sits inside the
|
||||||
at this size a lone chevron is a target you miss, and the name is
|
heading, not around it: a group *is* a heading, and wrapping an h2
|
||||||
where the eye already is. -->
|
in a button both invalidates the markup and takes the heading from
|
||||||
<!-- The chevron folds; the name opens the group's own page. Two
|
a screen reader. -->
|
||||||
targets in one heading, because they are two different intentions
|
|
||||||
and the old single one could only ever serve the smaller. -->
|
|
||||||
<button
|
<button
|
||||||
class="chevron-button"
|
class="fold-icon"
|
||||||
onclick={() => app.toggleGroup(group.id)}
|
onclick={() => app.toggleGroup(group.id)}
|
||||||
aria-expanded={!group.collapsed}
|
aria-expanded={!group.collapsed}
|
||||||
aria-label={group.collapsed
|
aria-label={group.collapsed
|
||||||
? t('sidebar.unfold', { name: group.name })
|
? t('sidebar.unfold', { name: group.name })
|
||||||
: t('sidebar.fold', { name: group.name })}
|
: t('sidebar.fold', { name: group.name })}
|
||||||
>
|
>
|
||||||
<svg
|
{@render sectionIcon('group')}
|
||||||
class="chevron"
|
|
||||||
class:collapsed={group.collapsed}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M6 9l6 6 6-6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<h2 class="fold">
|
<h2 class="fold">
|
||||||
<!-- Navigation waits two tenths of a second for a second click. A
|
<!-- Navigation waits two tenths of a second for a second click. A
|
||||||
@@ -975,7 +990,6 @@
|
|||||||
}, 200);
|
}, 200);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{@render sectionIcon('group')}
|
|
||||||
{group.name}
|
{group.name}
|
||||||
</a>
|
</a>
|
||||||
</h2>
|
</h2>
|
||||||
@@ -1063,12 +1077,12 @@
|
|||||||
{#if !app.connected}
|
{#if !app.connected}
|
||||||
<span class="offline" title={t('nav.offlineHint')}>{t('nav.offline')}</span>
|
<span class="offline" title={t('nav.offlineHint')}>{t('nav.offline')}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="user" onclick={signOut}>
|
<button class="user" class:confirming={confirmSignOut} onclick={onSignOutClick}>
|
||||||
<span class="avatar" style="background: {app.user?.avatar_color}">
|
<span class="avatar" style="background: {app.user?.avatar_color}">
|
||||||
{app.user?.name?.[0]?.toUpperCase() ?? '?'}
|
{app.user?.name?.[0]?.toUpperCase() ?? '?'}
|
||||||
</span>
|
</span>
|
||||||
<span class="user-name">{app.user?.name}</span>
|
<span class="user-name">{confirmSignOut ? t('nav.signOutConfirm') : app.user?.name}</span>
|
||||||
<span class="signout">{t('nav.signOut')}</span>
|
<span class="signout">{confirmSignOut ? '' : t('nav.signOut')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1104,14 +1118,16 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--s5);
|
gap: var(--s5);
|
||||||
padding: var(--s4) var(--s3);
|
/* A few pixels more air down each side than the rows strictly need, so the
|
||||||
|
menu breathes rather than sitting flush against its own edges. */
|
||||||
|
padding: var(--s4) calc(var(--s3) + 3px);
|
||||||
background: var(--surface-sunken);
|
background: var(--surface-sunken);
|
||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
/* Selve bjælken ruller ikke længere — .scroller gør. `hidden` frem for
|
/* Selve bjælken ruller ikke længere — .scroller gør. `hidden` frem for
|
||||||
ingenting, fordi den stadig er den yderste kant: løber en række over,
|
ingenting, fordi den stadig er den yderste kant: løber en række over,
|
||||||
skal den klippes her og ikke skubbe kolonnen bredere. */
|
skal den klippes her og ikke skubbe kolonnen bredere. */
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding-left: max(var(--s3), env(safe-area-inset-left));
|
padding-left: max(calc(var(--s3) + 3px), env(safe-area-inset-left));
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1330,7 +1346,9 @@
|
|||||||
h2 {
|
h2 {
|
||||||
font-size: var(--menu-size);
|
font-size: var(--menu-size);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--ink);
|
/* Kul sets these headings to a quiet grey (--heading-ink); every other theme
|
||||||
|
falls back to the reading ink and keeps them bold and bright. */
|
||||||
|
color: var(--heading-ink, var(--ink));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The heading's mark, in the muted ink so the bold name still leads. Sized and
|
/* The heading's mark, in the muted ink so the bold name still leads. Sized and
|
||||||
@@ -1425,35 +1443,17 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The chevron on its own, so the name beside it can be a link to the group's
|
/* The folder icon is the fold toggle now — no chevron beside it. A bare icon
|
||||||
page. Sized as a target rather than as a glyph: 11px of arrow is something
|
button the size of the icon it holds: the name next to it opens the group's
|
||||||
you miss. */
|
page, this folds it, and neither wears an arrow to say so. */
|
||||||
/* The chevron sits on top of the group's dot rather than before it. In the flow
|
.fold-icon {
|
||||||
it pushed the name 22px further in than a project's, so groups read as if
|
|
||||||
they were filed under the projects above them — the opposite of what they
|
|
||||||
are. Out of the flow, a group's name starts exactly where a project's does.
|
|
||||||
The dot is the resting state; the chevron takes over on hover or focus, and
|
|
||||||
stays put while the group is folded, so a folded group still shows the way
|
|
||||||
back open. */
|
|
||||||
/* Uden baggrund. Knappen dækkede prikken med et felt i --surface, og på den
|
|
||||||
sænkede bjælke er det en lys firkant: en foldet gruppe stod med en hvid
|
|
||||||
klods ud for navnet permanent, fordi chevronen bliver stående, når gruppen
|
|
||||||
er lukket. Prikken tones ud i stedet for at blive dækket — det er samme
|
|
||||||
skifte, uden noget at male henover den med. */
|
|
||||||
/* A group folds from its own chevron, the same one the system sections carry —
|
|
||||||
in the flow before the name now, not an absolute mark that appeared on hover,
|
|
||||||
so a group and a section read identically at rest. The name beside it is still
|
|
||||||
a link to the group's page; the chevron is the second intention. */
|
|
||||||
.chevron-button {
|
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chevron-button:hover {
|
.fold-icon:hover {
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1482,22 +1482,6 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chevron {
|
|
||||||
width: 11px;
|
|
||||||
height: 11px;
|
|
||||||
flex: none;
|
|
||||||
fill: none;
|
|
||||||
stroke: currentColor;
|
|
||||||
stroke-width: 2.4;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
transition: transform var(--fast) var(--ease);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chevron.collapsed {
|
|
||||||
transform: rotate(-90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hidden until the heading is hovered, like the section actions on a project
|
/* Hidden until the heading is hovered, like the section actions on a project
|
||||||
page: two buttons permanently beside a label stop it reading as a label. */
|
page: two buttons permanently beside a label stop it reading as a label. */
|
||||||
.folder-head .count {
|
.folder-head .count {
|
||||||
@@ -1619,7 +1603,10 @@
|
|||||||
in both themes. */
|
in both themes. */
|
||||||
a.active {
|
a.active {
|
||||||
background: var(--accent-sunken);
|
background: var(--accent-sunken);
|
||||||
color: var(--ink);
|
/* On a solid accent (Kul's amber menu item) the label goes dark via
|
||||||
|
--nav-active-ink; on the tinted accent-sunken every other theme uses, it
|
||||||
|
falls back to the reading ink. */
|
||||||
|
color: var(--nav-active-ink, var(--ink));
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1742,6 +1729,16 @@
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Asking to confirm: the row goes to the danger tint and the name is replaced by
|
||||||
|
"click again", so the second click is plainly the one that signs out. */
|
||||||
|
.user.confirming {
|
||||||
|
background: var(--danger-sunken);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user.confirming .user-name {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
|
|||||||
@@ -228,13 +228,21 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
padding: var(--s3) var(--s2);
|
padding: var(--s3) var(--s2);
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
transition:
|
transition:
|
||||||
opacity var(--medium) var(--ease),
|
opacity var(--medium) var(--ease),
|
||||||
transform var(--medium) var(--ease);
|
transform var(--medium) var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.row:hover {
|
/* Hover only where there is a real pointer. On a touch screen a :hover on the tap
|
||||||
background: var(--surface);
|
target makes iOS spend the first tap applying the hover and only open on the
|
||||||
|
second — the "double-tap" on iPhone. With no hover to apply, the first tap opens
|
||||||
|
the task. The rounded corners are on the row itself, so the highlight is a
|
||||||
|
rounded box like a note in the list. */
|
||||||
|
@media (hover: hover) {
|
||||||
|
.row:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The exit: a short lift and fade, so completing something reads as the task
|
/* The exit: a short lift and fade, so completing something reads as the task
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Connectivity, as the app experiences it — not as the browser reports it.
|
||||||
|
*
|
||||||
|
* This is the WRITE side of surviving a short drop — a few seconds of lost Wi-Fi, a
|
||||||
|
* tunnel, a base station handing over. The READ side lives in the service worker,
|
||||||
|
* which keeps the last-seen task and note lists so a reload through a drop is not a
|
||||||
|
* blank screen. This store is the other half: it is what keeps the save you just made
|
||||||
|
* from being reverted under your hands while the network is briefly gone.
|
||||||
|
*
|
||||||
|
* The request layer drives this: it retries a failed request for a short window
|
||||||
|
* (see api.js) and tells this store while it is trying, so the interface can say
|
||||||
|
* "reconnecting" rather than reverting the change under the user's hands. If the
|
||||||
|
* window runs out it is a real outage, and then the normal error path takes over.
|
||||||
|
*
|
||||||
|
* `navigator.onLine` is only a hint — it goes false when the machine has no route,
|
||||||
|
* but it stays true for a dead gateway or a server that is down, so it cannot be
|
||||||
|
* the source of truth. It is used here only to wake a waiting retry the moment the
|
||||||
|
* OS sees a link again, which is the one thing it is reliable for.
|
||||||
|
*/
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
class Connectivity {
|
||||||
|
/** 'online' — every recent request reached the server.
|
||||||
|
* 'retrying' — a request failed on the network and is being retried.
|
||||||
|
* 'lost' — a request gave up after the retry window; a real outage. */
|
||||||
|
status = $state('online');
|
||||||
|
|
||||||
|
/** A request reached the server. The network is up, whatever it was doing. */
|
||||||
|
reachable() {
|
||||||
|
this.status = 'online';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A request failed on the network and is about to be tried again. */
|
||||||
|
retrying() {
|
||||||
|
if (this.status !== 'lost') this.status = 'retrying';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A request used up its retry window without reaching the server. */
|
||||||
|
lost() {
|
||||||
|
this.status = 'lost';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const connectivity = new Connectivity();
|
||||||
|
|
||||||
|
if (browser) {
|
||||||
|
// The OS lost its link: say so at once rather than waiting for the next request
|
||||||
|
// to fail. It does not, on its own, mean the server is unreachable — but with no
|
||||||
|
// route there is nothing a save can do but wait, and the honest thing is to show
|
||||||
|
// it waiting.
|
||||||
|
window.addEventListener('offline', () => connectivity.retrying());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleeps for `ms`, but wakes early the moment the OS reports a link again — so a
|
||||||
|
* retry that is biding its time does not sit out the last two seconds of a blip
|
||||||
|
* that has already ended.
|
||||||
|
*/
|
||||||
|
export function sleepOrOnline(ms) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let timer;
|
||||||
|
const done = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (browser) window.removeEventListener('online', done);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
timer = setTimeout(done, ms);
|
||||||
|
if (browser) window.addEventListener('online', done, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -119,6 +119,7 @@ export const da = {
|
|||||||
'nav.calendar': 'Kalender',
|
'nav.calendar': 'Kalender',
|
||||||
'nav.settings': 'Indstillinger',
|
'nav.settings': 'Indstillinger',
|
||||||
'nav.signOut': 'Log ud',
|
'nav.signOut': 'Log ud',
|
||||||
|
'nav.signOutConfirm': 'Tryk igen for at logge ud',
|
||||||
'nav.main': 'Hovedmenu',
|
'nav.main': 'Hovedmenu',
|
||||||
'nav.search': 'Søg',
|
'nav.search': 'Søg',
|
||||||
'nav.searchLong': 'Søg i opgaver og projekter',
|
'nav.searchLong': 'Søg i opgaver og projekter',
|
||||||
@@ -130,6 +131,8 @@ export const da = {
|
|||||||
'nav.toggleTheme': 'Skift mellem lyst og mørkt tema',
|
'nav.toggleTheme': 'Skift mellem lyst og mørkt tema',
|
||||||
'nav.offline': 'Offline',
|
'nav.offline': 'Offline',
|
||||||
'nav.offlineHint': 'Ændringer fra andre vises ikke lige nu',
|
'nav.offlineHint': 'Ændringer fra andre vises ikke lige nu',
|
||||||
|
'net.reconnecting': 'Genopretter forbindelse…',
|
||||||
|
'net.offline': 'Ingen forbindelse',
|
||||||
'nav.doItToday': 'Gør det i dag',
|
'nav.doItToday': 'Gør det i dag',
|
||||||
|
|
||||||
// --- the sidebar's own sections ------------------------------------------------
|
// --- the sidebar's own sections ------------------------------------------------
|
||||||
@@ -841,6 +844,8 @@ export const da = {
|
|||||||
// --- the themes -------------------------------------------------------------------
|
// --- the themes -------------------------------------------------------------------
|
||||||
'theme.dark': 'Nordlys',
|
'theme.dark': 'Nordlys',
|
||||||
'theme.darkNote': 'Mørk med grøn accent.',
|
'theme.darkNote': 'Mørk med grøn accent.',
|
||||||
|
'theme.charcoal': 'Kul',
|
||||||
|
'theme.charcoalNote': 'Neutral mørkegrå.',
|
||||||
'theme.dusk': 'Skumring',
|
'theme.dusk': 'Skumring',
|
||||||
'theme.duskNote': 'Mørk og varm, med rav.',
|
'theme.duskNote': 'Mørk og varm, med rav.',
|
||||||
'theme.light': 'Dagslys',
|
'theme.light': 'Dagslys',
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ export const en = {
|
|||||||
'nav.calendar': 'Calendar',
|
'nav.calendar': 'Calendar',
|
||||||
'nav.settings': 'Settings',
|
'nav.settings': 'Settings',
|
||||||
'nav.signOut': 'Sign out',
|
'nav.signOut': 'Sign out',
|
||||||
|
'nav.signOutConfirm': 'Click again to sign out',
|
||||||
'nav.main': 'Main menu',
|
'nav.main': 'Main menu',
|
||||||
'nav.search': 'Search',
|
'nav.search': 'Search',
|
||||||
'nav.searchLong': 'Search tasks and projects',
|
'nav.searchLong': 'Search tasks and projects',
|
||||||
@@ -126,6 +127,8 @@ export const en = {
|
|||||||
'nav.toggleTheme': 'Switch between light and dark',
|
'nav.toggleTheme': 'Switch between light and dark',
|
||||||
'nav.offline': 'Offline',
|
'nav.offline': 'Offline',
|
||||||
'nav.offlineHint': 'Changes from other people are not showing right now',
|
'nav.offlineHint': 'Changes from other people are not showing right now',
|
||||||
|
'net.reconnecting': 'Reconnecting…',
|
||||||
|
'net.offline': 'No connection',
|
||||||
'nav.doItToday': 'Do it today',
|
'nav.doItToday': 'Do it today',
|
||||||
|
|
||||||
// --- the sidebar's own sections ------------------------------------------------
|
// --- the sidebar's own sections ------------------------------------------------
|
||||||
@@ -834,6 +837,8 @@ export const en = {
|
|||||||
// --- the themes -------------------------------------------------------------------
|
// --- the themes -------------------------------------------------------------------
|
||||||
'theme.dark': 'Northern lights',
|
'theme.dark': 'Northern lights',
|
||||||
'theme.darkNote': 'Dark, with a green accent.',
|
'theme.darkNote': 'Dark, with a green accent.',
|
||||||
|
'theme.charcoal': 'Charcoal',
|
||||||
|
'theme.charcoalNote': 'Neutral dark grey.',
|
||||||
'theme.dusk': 'Dusk',
|
'theme.dusk': 'Dusk',
|
||||||
'theme.duskNote': 'Dark and warm, with amber.',
|
'theme.duskNote': 'Dark and warm, with amber.',
|
||||||
'theme.light': 'Daylight',
|
'theme.light': 'Daylight',
|
||||||
|
|||||||
@@ -26,9 +26,13 @@ export function permission() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function registration() {
|
async function registration() {
|
||||||
// `ready` waits for an active worker; register() alone resolves while the
|
// SvelteKit registers src/service-worker.js itself on load, so normally there
|
||||||
// worker is still installing, and subscribing against that fails.
|
// is already a registration here. Register it by hand only if there is not yet
|
||||||
await navigator.serviceWorker.register('/sw.js');
|
// one — a push subscribe that races the app's own registration would otherwise
|
||||||
|
// find nothing. `ready` waits for an active worker; register() alone resolves
|
||||||
|
// while the worker is still installing, and subscribing against that fails.
|
||||||
|
const existing = await navigator.serviceWorker.getRegistration();
|
||||||
|
if (!existing) await navigator.serviceWorker.register('/service-worker.js');
|
||||||
return navigator.serviceWorker.ready;
|
return navigator.serviceWorker.ready;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1006,6 +1006,7 @@ export const completedView = new CompletedView();
|
|||||||
*/
|
*/
|
||||||
export const THEMES = [
|
export const THEMES = [
|
||||||
{ id: 'dark', name: 'theme.dark', note: 'theme.darkNote', dark: true },
|
{ id: 'dark', name: 'theme.dark', note: 'theme.darkNote', dark: true },
|
||||||
|
{ id: 'charcoal', name: 'theme.charcoal', note: 'theme.charcoalNote', dark: true },
|
||||||
{ id: 'dusk', name: 'theme.dusk', note: 'theme.duskNote', dark: true },
|
{ id: 'dusk', name: 'theme.dusk', note: 'theme.duskNote', dark: true },
|
||||||
{ id: 'light', name: 'theme.light', note: 'theme.lightNote', dark: false },
|
{ id: 'light', name: 'theme.light', note: 'theme.lightNote', dark: false },
|
||||||
{ id: 'paper', name: 'theme.paper', note: 'theme.paperNote', dark: false },
|
{ id: 'paper', name: 'theme.paper', note: 'theme.paperNote', dark: false },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { app, theme, sidebar } from '$lib/stores.svelte.js';
|
import { app, theme, sidebar } from '$lib/stores.svelte.js';
|
||||||
|
import { connectivity } from '$lib/connectivity.svelte.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||||
@@ -14,6 +15,29 @@
|
|||||||
let paletteOpen = $state(false);
|
let paletteOpen = $state(false);
|
||||||
let sidebarOpen = $state(false);
|
let sidebarOpen = $state(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show the "reconnecting" pill.
|
||||||
|
*
|
||||||
|
* Held back seven tenths of a second while merely retrying, so a blip that ends
|
||||||
|
* on the first retry never flashes a pill nobody needed to see. A retry that has
|
||||||
|
* already given up ('lost') is a real error and shown at once — the delay is only
|
||||||
|
* for the hopeful case.
|
||||||
|
*/
|
||||||
|
let showNet = $state(false);
|
||||||
|
$effect(() => {
|
||||||
|
const s = connectivity.status;
|
||||||
|
if (s === 'online') {
|
||||||
|
showNet = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (s === 'lost') {
|
||||||
|
showNet = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => (showNet = true), 700);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The two pages that arrive by email, and are for somebody who is not signed in.
|
* The two pages that arrive by email, and are for somebody who is not signed in.
|
||||||
*
|
*
|
||||||
@@ -204,6 +228,16 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- A short drop, said quietly: it appears only if a save is still retrying after
|
||||||
|
the better part of a second, and leaves the moment the connection is back — so
|
||||||
|
a two-second blip that heals itself is felt as nothing at all. -->
|
||||||
|
{#if showNet}
|
||||||
|
<div class="netstatus" class:lost={connectivity.status === 'lost'} role="status">
|
||||||
|
<span class="net-dot" aria-hidden="true"></span>
|
||||||
|
{connectivity.status === 'lost' ? t('net.offline') : t('net.reconnecting')}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -378,6 +412,62 @@
|
|||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The reconnecting pill: at the top, small, centred. Not a toast — a toast is a
|
||||||
|
thing that happened and is over, this is a thing still happening. */
|
||||||
|
.netstatus {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(var(--s3) + env(safe-area-inset-top));
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 60;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
padding: var(--s1) var(--s3);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.14);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.net-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--color-amber);
|
||||||
|
animation: net-pulse 1.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Given up: an amber pulse becomes a still red, the same red a failed toast
|
||||||
|
carries, so "still trying" and "gave up" do not look alike. */
|
||||||
|
.netstatus.lost {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.netstatus.lost .net-dot {
|
||||||
|
background: var(--p1);
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes net-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.net-dot {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -138,6 +138,7 @@
|
|||||||
|
|
||||||
let totpSecret = $state('');
|
let totpSecret = $state('');
|
||||||
let totpURI = $state('');
|
let totpURI = $state('');
|
||||||
|
let totpQR = $state('');
|
||||||
let totpCode = $state('');
|
let totpCode = $state('');
|
||||||
let totpErrors = $state({});
|
let totpErrors = $state({});
|
||||||
let recoveryCodes = $state([]);
|
let recoveryCodes = $state([]);
|
||||||
@@ -163,6 +164,7 @@
|
|||||||
enablePassword = '';
|
enablePassword = '';
|
||||||
totpSecret = r.secret;
|
totpSecret = r.secret;
|
||||||
totpURI = r.uri;
|
totpURI = r.uri;
|
||||||
|
totpQR = r.qr;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
totpErrors = e.fields ?? {};
|
totpErrors = e.fields ?? {};
|
||||||
if (!Object.keys(totpErrors).length) app.toast(humanMessage(e));
|
if (!Object.keys(totpErrors).length) app.toast(humanMessage(e));
|
||||||
@@ -177,6 +179,7 @@
|
|||||||
recoveryCodes = r.recovery_codes;
|
recoveryCodes = r.recovery_codes;
|
||||||
totpSecret = '';
|
totpSecret = '';
|
||||||
totpURI = '';
|
totpURI = '';
|
||||||
|
totpQR = '';
|
||||||
totpCode = '';
|
totpCode = '';
|
||||||
app.user = await api.me();
|
app.user = await api.me();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -492,8 +495,13 @@
|
|||||||
{:else if totpSecret}
|
{:else if totpSecret}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<p class="hint">{t('account.scanHint')}</p>
|
<p class="hint">{t('account.scanHint')}</p>
|
||||||
|
{#if totpQR}
|
||||||
|
<!-- The server renders the otpauth URI as an SVG QR — one dark path on a
|
||||||
|
white square, so it scans in either theme. It is the URI it already
|
||||||
|
sent, drawn; nothing here builds it. -->
|
||||||
|
<div class="qr">{@html totpQR}</div>
|
||||||
|
{/if}
|
||||||
<p class="mono secret">{totpSecret}</p>
|
<p class="mono secret">{totpSecret}</p>
|
||||||
<p class="hint mono uri">{totpURI}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onsubmit={confirmTOTP}>
|
<form onsubmit={confirmTOTP}>
|
||||||
@@ -514,7 +522,14 @@
|
|||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button class="primary" type="submit">{t('account.confirm')}</button>
|
<button class="primary" type="submit">{t('account.confirm')}</button>
|
||||||
<button class="secondary" onclick={() => (totpSecret = '')}>{t('account.cancel')}</button>
|
<button
|
||||||
|
class="secondary"
|
||||||
|
onclick={() => {
|
||||||
|
totpSecret = '';
|
||||||
|
totpURI = '';
|
||||||
|
totpQR = '';
|
||||||
|
}}>{t('account.cancel')}</button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -859,6 +874,23 @@
|
|||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* On its own white card whatever the theme: a QR is dark-on-light or it does not
|
||||||
|
scan, so the square keeps its own background rather than taking the page's. */
|
||||||
|
.qr {
|
||||||
|
width: 176px;
|
||||||
|
height: 176px;
|
||||||
|
padding: var(--s2);
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr :global(svg) {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.secret {
|
.secret {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: var(--s3);
|
padding: var(--s3);
|
||||||
@@ -869,10 +901,6 @@
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uri {
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The understated variant: present, not loud. Bordered and in the muted ink so
|
/* The understated variant: present, not loud. Bordered and in the muted ink so
|
||||||
it reads as a footnote, brightening on hover. The cup is outline-only. */
|
it reads as a footnote, brightening on hover. The cup is outline-only. */
|
||||||
.bmc {
|
.bmc {
|
||||||
|
|||||||
@@ -285,7 +285,7 @@
|
|||||||
<p class="hint">{t('data.doneHint')}</p>
|
<p class="hint">{t('data.doneHint')}</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<a class="ghost" href="/faerdige">{t('data.openDone')}</a>
|
<a class="link" href="/faerdige">{t('data.openDone')}</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -132,7 +132,10 @@
|
|||||||
if (days === 1) return { key: 'i-gaar', label: t('notes.groupYesterday') };
|
if (days === 1) return { key: 'i-gaar', label: t('notes.groupYesterday') };
|
||||||
if (days < 7) return { key: 'ugen', label: t('notes.groupWeek') };
|
if (days < 7) return { key: 'ugen', label: t('notes.groupWeek') };
|
||||||
|
|
||||||
const label = at.toLocaleDateString(tag(), { month: 'long', year: 'numeric' });
|
// Stort forbogstav på måneden: Intl giver "august 2026" på dansk, og en
|
||||||
|
// overskrift begynder med stort.
|
||||||
|
const month = at.toLocaleDateString(tag(), { month: 'long', year: 'numeric' });
|
||||||
|
const label = month.charAt(0).toUpperCase() + month.slice(1);
|
||||||
return { key: `m-${at.getFullYear()}-${at.getMonth()}`, label };
|
return { key: `m-${at.getFullYear()}-${at.getMonth()}`, label };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -704,7 +707,10 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="notes">
|
<!-- `has-note` drives the phone layout: one column, showing the list until a note
|
||||||
|
is opened and the editor once one is. On a wide screen both are always up and
|
||||||
|
the class does nothing. -->
|
||||||
|
<div class="notes" class:has-note={selectedId}>
|
||||||
<aside>
|
<aside>
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<h1>{t('notes.title')}</h1>
|
<h1>{t('notes.title')}</h1>
|
||||||
@@ -810,7 +816,11 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="mark" class:closed={folded.has(group.key)} aria-hidden="true">▾</span>
|
<!-- No arrow, to match the sidebar: the box folds on a click,
|
||||||
|
the star and the people icon are the marks that mean
|
||||||
|
something, and a date group has none — an empty spacer, so
|
||||||
|
every label still starts at the same place. -->
|
||||||
|
<span class="mark" aria-hidden="true"></span>
|
||||||
{/if}
|
{/if}
|
||||||
{group.label}
|
{group.label}
|
||||||
<span class="count">{group.notes.length}</span>
|
<span class="count">{group.notes.length}</span>
|
||||||
@@ -831,41 +841,38 @@
|
|||||||
ondragstart={(e) => onDragStart(e, note)}
|
ondragstart={(e) => onDragStart(e, note)}
|
||||||
ondragend={() => (dragging = null)}
|
ondragend={() => (dragging = null)}
|
||||||
>
|
>
|
||||||
<!-- Titel og projekt på linje ét, uddrag på linje to. Datoen står
|
<!-- Tre linjer, som Apple Noter: titlen, så datoen og uddraget, og
|
||||||
i gruppens overskrift og ikke her — undtagen når der er sorteret
|
nederst hvor noten er lagt. Datoen står forrest på linje to og
|
||||||
på navn, for så er der ingen overskrift, der siger den, og så er
|
venstrestillet, ikke ude til højre. -->
|
||||||
den det eneste tilbage, der gør. -->
|
|
||||||
<span class="head">
|
<span class="head">
|
||||||
<strong>{note.title || t('notes.untitled')}</strong>
|
<strong>{note.title || t('notes.untitled')}</strong>
|
||||||
{#if order === 'title'}
|
</span>
|
||||||
<span class="when" title={stamp(note)}>{when(shownDate(note))}</span>
|
<span class="preview">
|
||||||
{/if}
|
<span class="when" title={stamp(note)}>{when(shownDate(note))}</span>
|
||||||
{#if note.project_id}
|
<span class="snippet">{preview(note)}</span>
|
||||||
<!-- Projektets egen farve, den samme som prikken i sidebjælken.
|
</span>
|
||||||
Mærkaten sagde det samme som alle de andre i en grå, der var
|
<!-- Linje tre: hvor noten er lagt, med den samme mappe som grupperne i
|
||||||
lysere end uddraget — og projektet er dét, man skanner efter,
|
menuen — projektets navn, ejerens hvis den er en andens, ellers bare
|
||||||
når man leder i tolv hundrede noter. Nu kan Hjem kendes fra
|
"Noter", når den ikke hører til et projekt. -->
|
||||||
Claude uden at læse ordet. -->
|
<span class="filed">
|
||||||
<span class="filed" style="--project: {projectColour(note.project_id)}">
|
|
||||||
<span class="dot" aria-hidden="true"></span>
|
|
||||||
{projectName(note.project_id)}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if note.shared_with_me && note.owner}
|
{#if note.shared_with_me && note.owner}
|
||||||
<!-- Hvis note delt med mig: hvem den kom fra, i personens egen
|
<svg class="filed-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
avatar-farve. Samme form som projekt-mærkaten, så listen har
|
<circle cx="8" cy="6" r="2.3" fill="none" stroke="currentColor" stroke-width="1.4" />
|
||||||
ét sprog for "det her hører til nogen". -->
|
<path d="M3.6 13c0-2.5 2-4 4.4-4s4.4 1.5 4.4 4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" />
|
||||||
<span
|
</svg>
|
||||||
class="filed person"
|
<span class="filed-name" title={t('notes.sharedBy', { name: note.owner.name })}>{note.owner.name}</span>
|
||||||
style="--project: {note.owner.avatar_color}"
|
{:else if note.project_id}
|
||||||
title={t('notes.sharedBy', { name: note.owner.name })}
|
<svg class="filed-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
>
|
<path d="M2 4.6a1 1 0 011-1h3.1l1.4 1.5H13a1 1 0 011 1v6.3a1 1 0 01-1 1H3a1 1 0 01-1-1z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" />
|
||||||
<span class="dot" aria-hidden="true"></span>
|
</svg>
|
||||||
{note.owner.name}
|
<span class="filed-name">{projectName(note.project_id)}</span>
|
||||||
</span>
|
{:else}
|
||||||
|
<svg class="filed-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<path d="M2 4.6a1 1 0 011-1h3.1l1.4 1.5H13a1 1 0 011 1v6.3a1 1 0 01-1 1H3a1 1 0 01-1-1z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<span class="filed-name">{t('notes.title')}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="preview">{preview(note)}</span>
|
|
||||||
</button>
|
</button>
|
||||||
<!-- De to knapper står oven på hinanden i en smal søjle.
|
<!-- De to knapper står oven på hinanden i en smal søjle.
|
||||||
Ved siden af hinanden tog de toogfyrre pixels af en liste,
|
Ved siden af hinanden tog de toogfyrre pixels af en liste,
|
||||||
@@ -912,7 +919,15 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="editor">
|
<section class="editor">
|
||||||
{#if selected}
|
{#if selectedId}
|
||||||
|
<!-- Keyed on the id, which is set the instant a row is tapped — not on the
|
||||||
|
loaded note, which arrives a moment later. On a phone that moment was the
|
||||||
|
whole bug: the pane did not switch until the body had loaded, so the first
|
||||||
|
tap looked like nothing and people tapped again. -->
|
||||||
|
<!-- Phone only (hidden on a wide screen by CSS): the way back to the list,
|
||||||
|
since there the editor fills the screen and the list is not beside it. -->
|
||||||
|
<button class="back" onclick={() => open(null)}>‹ {t('notes.title')}</button>
|
||||||
|
{#if selected}
|
||||||
<NoteEditor
|
<NoteEditor
|
||||||
note={selected}
|
note={selected}
|
||||||
notes={notes}
|
notes={notes}
|
||||||
@@ -1046,6 +1061,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
{:else}
|
||||||
|
<!-- Chosen, but its full text is still loading. Keep the pane (so the tap
|
||||||
|
felt like it landed) and stay quiet — the "pick a note" line is for
|
||||||
|
when nothing is chosen at all, not for this half-second. -->
|
||||||
|
<p class="empty" aria-busy="true"></p>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<p class="empty">{t('notes.pickOne')}</p>
|
<p class="empty">{t('notes.pickOne')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1064,8 +1085,9 @@
|
|||||||
against the sidebar's rule, which reads as a rendering fault rather than
|
against the sidebar's rule, which reads as a rendering fault rather than
|
||||||
as a layout. */
|
as a layout. */
|
||||||
/* Luft hele vejen rundt om arket. Højre og bund var nul, dengang editoren
|
/* Luft hele vejen rundt om arket. Højre og bund var nul, dengang editoren
|
||||||
ingen kant havde og ingenting kunne støde imod. */
|
ingen kant havde og ingenting kunne støde imod. Et par pixels mere om
|
||||||
padding: var(--s3) var(--s4) var(--s3) var(--s4);
|
notelisten, så kortet ikke ligger klods op ad menuen. */
|
||||||
|
padding: var(--s3) var(--s4) var(--s3) calc(var(--s4) + 3px);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(220px, 300px) 1fr;
|
grid-template-columns: minmax(220px, 300px) 1fr;
|
||||||
gap: var(--s4);
|
gap: var(--s4);
|
||||||
@@ -1079,13 +1101,15 @@
|
|||||||
gap: var(--s2);
|
gap: var(--s2);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border-right: 1px solid var(--line);
|
/* A rounded card, the same corner and border as the editor beside it — the
|
||||||
padding-right: var(--s3);
|
list is its own sheet, not a column ruled off the page. */
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
/* White in light, a nuance lighter than the ground in dark — the note list
|
/* White in light, a nuance lighter than the ground in dark — the note list
|
||||||
is a surface you read down, so it lifts off the page like the editor does.
|
is a surface you read down, so it lifts off the page like the editor does.
|
||||||
--surface-raised is #fff in the light themes and one step up in the dark. */
|
--surface-raised is #fff in the light themes and one step up in the dark. */
|
||||||
background: var(--surface-raised);
|
background: var(--surface-raised);
|
||||||
padding-left: var(--s3);
|
padding: var(--s3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.head {
|
.head {
|
||||||
@@ -1322,13 +1346,20 @@
|
|||||||
* målt på en rigtig række: titel plus én linje uddrag. */
|
* målt på en rigtig række: titel plus én linje uddrag. */
|
||||||
li {
|
li {
|
||||||
content-visibility: auto;
|
content-visibility: auto;
|
||||||
contain-intrinsic-size: auto 54px;
|
contain-intrinsic-size: auto 74px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A faint hairline between notes, as Apple Notes has — the group boxes give the
|
||||||
|
coarse structure, this separates one note from the next inside a group. */
|
||||||
.rowline {
|
.rowline {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--s1);
|
gap: var(--s1);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
li:last-child .rowline {
|
||||||
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* De sjældne handlinger i en søjle, ikke en række. To knapper ved siden af
|
/* De sjældne handlinger i en søjle, ikke en række. To knapper ved siden af
|
||||||
@@ -1367,43 +1398,51 @@
|
|||||||
*
|
*
|
||||||
* Baggrunden er ikke gennemsigtig. En klæbende overskrift uden en er en
|
* Baggrunden er ikke gennemsigtig. En klæbende overskrift uden en er en
|
||||||
* overskrift med rækkerne kørende igennem sig. */
|
* overskrift med rækkerne kørende igennem sig. */
|
||||||
|
/* One design with the sidebar headings: a rounded box, the same corner as an
|
||||||
|
active menu item, filled just enough to separate one group of notes from the
|
||||||
|
next. The hard rule underneath is gone — the box is the separation now. */
|
||||||
h3.group {
|
h3.group {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
margin: 0;
|
margin: 0 0 var(--s1);
|
||||||
background: var(--ground);
|
/* --ground everywhere but Kul, where the ground is the darkest thing on screen
|
||||||
border-bottom: 1px solid var(--line);
|
and a day separator on it reads as a dark bar. There it takes the menu's own
|
||||||
|
resting colour instead, via --note-group-bg. */
|
||||||
|
background: var(--note-group-bg, var(--ground));
|
||||||
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bold and plainly cased, in the reading ink — the faint uppercase label read as
|
||||||
|
a different app's heading beside the sidebar's. Menu size, so the two tiers of
|
||||||
|
heading in front of the eye at once are the same size. */
|
||||||
h3.group button {
|
h3.group button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--s2);
|
gap: var(--s2);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--s2) var(--s2);
|
padding: var(--s2) var(--s2);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--menu-size);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.07em;
|
letter-spacing: normal;
|
||||||
text-transform: uppercase;
|
text-transform: none;
|
||||||
color: var(--ink-faint);
|
color: var(--ink);
|
||||||
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
h3.group button:hover {
|
h3.group button:hover {
|
||||||
color: var(--ink-muted);
|
background: var(--surface-sunken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The mark, or an empty box the same width where a date group has none, so every
|
||||||
|
label starts at the same place. The star and the people icon fill it; the
|
||||||
|
arrow that used to is gone — the box folds on a click. */
|
||||||
h3.group .mark {
|
h3.group .mark {
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 0.75em;
|
width: 0.75em;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
transition: transform var(--fast) var(--ease);
|
|
||||||
}
|
|
||||||
|
|
||||||
h3.group .mark.closed {
|
|
||||||
transform: rotate(-90deg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sit eget navn og ikke `.star`. Rækkens favoritknap hedder også det og er
|
/* Sit eget navn og ikke `.star`. Rækkens favoritknap hedder også det og er
|
||||||
@@ -1419,6 +1458,7 @@
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
|
color: var(--ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stregen mellem rækkerne er væk.
|
/* Stregen mellem rækkerne er væk.
|
||||||
@@ -1455,13 +1495,38 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row:hover {
|
/* Pointer only. On a touch screen a :hover on the tap target costs a tap — iOS
|
||||||
background: var(--surface);
|
spends the first applying the hover and opens on the second. Gated, the first
|
||||||
|
tap opens the note. The row's buttons are shown outright on touch below, since
|
||||||
|
there is no hover to reveal them. */
|
||||||
|
@media (hover: hover) {
|
||||||
|
.row:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.star,
|
||||||
|
.archive-one {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.row.on {
|
.row.on {
|
||||||
background: var(--surface-raised);
|
background: var(--note-on-bg, var(--surface-raised));
|
||||||
color: var(--ink);
|
color: var(--note-on-ink, var(--ink));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On a themed selection colour (Kul's gold), the row's own quieter tiers go dark
|
||||||
|
too, or the date and the filed line vanish into it. Other themes have no
|
||||||
|
--note-on-ink, so these fall back to their normal muted inks. */
|
||||||
|
.row.on .when,
|
||||||
|
.row.on .filed {
|
||||||
|
color: var(--note-on-ink, var(--ink-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.on .filed-icon {
|
||||||
|
color: var(--note-on-ink, var(--ink-faint));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Samme størrelse som en opgavetitel. En note og en opgave er to ting af samme
|
/* Samme størrelse som en opgavetitel. En note og en opgave er to ting af samme
|
||||||
@@ -1480,19 +1545,25 @@
|
|||||||
/* Dato og begyndelse på samme linje, som Apple Noter gør det. To linjer pr.
|
/* Dato og begyndelse på samme linje, som Apple Noter gør det. To linjer pr.
|
||||||
note frem for tre, og datoen først, fordi det er den, man skanner efter. */
|
note frem for tre, og datoen først, fordi det er den, man skanner efter. */
|
||||||
|
|
||||||
|
/* Datoen forrest på linje to og venstrestillet, som Apple Noter — ikke skubbet
|
||||||
|
ud til højre. Tabular-tal, så en søjle af datoer står på lige linjer. */
|
||||||
.when {
|
.when {
|
||||||
flex: none;
|
flex: none;
|
||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* `display: block`, og det er ikke pynt.
|
/* Linje to er en flex-række: datoen, der ikke krymper, og uddraget, der klippes.
|
||||||
*
|
Afkortningen skal stå på uddraget selv — et inline-element klippes ikke af
|
||||||
* Uddraget lå før inde i en flex-boks, der ejede afkortningen. Da datoen flyttede
|
`overflow: hidden`. */
|
||||||
* op i gruppens overskrift, forsvandt den boks — og et inline-element klippes
|
|
||||||
* ikke af `overflow: hidden`, så den sidste sætning løb videre ind under
|
|
||||||
* favoritstjernen. Reglerne skal stå på det element, der faktisk skal klippes. */
|
|
||||||
.preview {
|
.preview {
|
||||||
display: block;
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.45em;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -1509,31 +1580,30 @@
|
|||||||
* Prikken bærer farven, ikke teksten. Fem projekters farver som skriftfarve på
|
* Prikken bærer farven, ikke teksten. Fem projekters farver som skriftfarve på
|
||||||
* en lys flade er fem forskellige grader af læsbarhed, og den lyseste af dem er
|
* en lys flade er fem forskellige grader af læsbarhed, og den lyseste af dem er
|
||||||
* ikke læsbar. */
|
* ikke læsbar. */
|
||||||
|
/* Linje tre: hvor noten er lagt. En mappe som grupperne i menuen og et navn,
|
||||||
|
venstrestillet under uddraget — ikke en mærkat skubbet ud til højre. */
|
||||||
.filed {
|
.filed {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.375em;
|
gap: 0.4em;
|
||||||
flex: none;
|
min-width: 0;
|
||||||
margin-left: auto;
|
margin-top: 1px;
|
||||||
max-width: 45%;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filed .dot {
|
.filed-icon {
|
||||||
width: 6px;
|
width: 13px;
|
||||||
height: 6px;
|
height: 13px;
|
||||||
flex: none;
|
flex: none;
|
||||||
border-radius: var(--radius-full);
|
color: var(--ink-faint);
|
||||||
background: var(--project, var(--line-strong));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filed::before {
|
.filed-name {
|
||||||
content: '▸ ';
|
min-width: 0;
|
||||||
opacity: 0.7;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Noten er et ark, ikke en flade i appen.
|
/* Noten er et ark, ikke en flade i appen.
|
||||||
@@ -1552,8 +1622,10 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
/* A nuance lighter than the ground in dark, still white in light — matches
|
/* A nuance lighter than the ground in dark, still white in light — matches
|
||||||
the note list beside it so the two read as one writing surface. */
|
the note list beside it so the two read as one writing surface. Kul gives
|
||||||
background: var(--surface-raised);
|
the text field its own darker grey via --editor-bg; other themes fall back
|
||||||
|
to the shared surface so the list and the editor stay the same colour. */
|
||||||
|
background: var(--editor-bg, var(--surface-raised));
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
/* Luft nok til at teksten ikke rører kanten.
|
/* Luft nok til at teksten ikke rører kanten.
|
||||||
@@ -1753,17 +1825,6 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The person chip: the project mark's shape in a person's own colour. No ▸
|
|
||||||
before it — the coloured dot already says "belongs to", and two markers on
|
|
||||||
one short chip is noise. It sits next to the title rather than pushed to the
|
|
||||||
right, so in the Delt med mig group it reads as "Ferieplan — Sofie". */
|
|
||||||
.filed.person {
|
|
||||||
margin-left: 0.5em;
|
|
||||||
max-width: 40%;
|
|
||||||
}
|
|
||||||
.filed.person::before {
|
|
||||||
content: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sharepanel .none {
|
.sharepanel .none {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
@@ -1883,14 +1944,48 @@
|
|||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The way back to the list on a phone — an iOS-style "‹ Noter" at the top of the
|
||||||
|
editor. Hidden on a wide screen, where the list is always beside the editor. */
|
||||||
|
.back {
|
||||||
|
display: none;
|
||||||
|
align-self: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s1);
|
||||||
|
margin-bottom: var(--s2);
|
||||||
|
padding: var(--s1) 0;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.notes {
|
.notes {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
aside {
|
/* One pane at a time. The two-column card layout has no room on a phone, so
|
||||||
border-right: 0;
|
the list shows until a note is opened and the editor takes over once one is
|
||||||
padding-right: 0;
|
— the back button is the way between them. Without this the editor stacked
|
||||||
|
below a full-height list, so a tapped note opened out of sight. */
|
||||||
|
.notes.has-note aside {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes:not(.has-note) .editor {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full-bleed: the card border and rounded corners are for sitting beside
|
||||||
|
another card, and on one column there is nothing beside it. */
|
||||||
|
aside,
|
||||||
|
.editor {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
/// <reference types="@sveltejs/kit" />
|
||||||
|
/* global self, caches, fetch, Response */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* verdande's service worker: Web Push, and a cache that holds through a short
|
||||||
|
* network drop.
|
||||||
|
*
|
||||||
|
* It used to do Push and nothing else, with a note that a cache serving yesterday's
|
||||||
|
* data was worse than an honest "cannot connect". The cache here does not do that.
|
||||||
|
* It is network-FIRST for everything that can go stale — a reader online always gets
|
||||||
|
* the live answer, and the cache is only reached for when the network is not — so it
|
||||||
|
* is not a copy of the app you work in offline, it is the app surviving a tunnel, a
|
||||||
|
* lift, a base-station handover. The five themes' worth of live task list stays live;
|
||||||
|
* it just no longer goes blank the moment the signal does.
|
||||||
|
*
|
||||||
|
* Writes are never touched here — they go straight to the network, and api.js already
|
||||||
|
* retries them across a short drop. Caching a write would mean a queue and conflicts,
|
||||||
|
* which is a different and much larger feature.
|
||||||
|
*/
|
||||||
|
import { build, files, version } from '$service-worker';
|
||||||
|
|
||||||
|
// Versioned, so a deploy's worth of new hashed assets lands in a fresh cache and the
|
||||||
|
// old one is dropped on activate — the app can never be served half of two builds.
|
||||||
|
const SHELL = `verdande-shell-${version}`;
|
||||||
|
// Not versioned: the data does not belong to a build, and clearing it every deploy
|
||||||
|
// would throw away the very copy that lets a reload survive a drop. Cleared on sign-out.
|
||||||
|
const API = 'verdande-api';
|
||||||
|
|
||||||
|
// The shell: every hashed build asset, the static files, and the SPA fallback so a
|
||||||
|
// navigation can be answered from the cache when the network cannot answer it.
|
||||||
|
const SHELL_ASSETS = [...build, ...files, '/'];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const cache = await caches.open(SHELL);
|
||||||
|
await cache.addAll(SHELL_ASSETS);
|
||||||
|
// Take over at the next opportunity rather than waiting for every tab to
|
||||||
|
// close — paired with clients.claim below and the versioned cache, a deploy
|
||||||
|
// reaches the user on their next load.
|
||||||
|
await self.skipWaiting();
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
for (const key of await caches.keys()) {
|
||||||
|
// Drop old shell versions; keep this shell and the data cache.
|
||||||
|
if (key !== SHELL && key !== API) await caches.delete(key);
|
||||||
|
}
|
||||||
|
await self.clients.claim();
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAsset = (pathname) => build.includes(pathname) || files.includes(pathname);
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
|
||||||
|
// Writes go to the network, full stop — the app retries them itself, and a cached
|
||||||
|
// write is a promise this cannot keep. Cross-origin is none of our business.
|
||||||
|
if (request.method !== 'GET') return;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
|
// Hashed build assets and static files never change for a given URL: cache-first,
|
||||||
|
// so a repeat load and an offline load are both instant.
|
||||||
|
if (isAsset(url.pathname)) {
|
||||||
|
event.respondWith(cacheFirst(request, SHELL));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The data: network-first so an online reader always sees the live list, with the
|
||||||
|
// last good copy kept for when the network is gone.
|
||||||
|
if (url.pathname.startsWith('/api/v1/')) {
|
||||||
|
event.respondWith(networkFirst(request, API));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A navigation (reload, or opening the app): the live page when there is one, the
|
||||||
|
// cached shell when there is not — so the app opens through a drop instead of
|
||||||
|
// showing the browser's dinosaur.
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
event.respondWith(navigate(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function cacheFirst(request, cacheName) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const hit = await cache.match(request);
|
||||||
|
if (hit) return hit;
|
||||||
|
const res = await fetch(request);
|
||||||
|
if (res.ok) cache.put(request, res.clone());
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function networkFirst(request, cacheName) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
try {
|
||||||
|
const res = await fetch(request);
|
||||||
|
// Only a real answer is worth keeping — a 401 or a 500 must not overwrite the
|
||||||
|
// last good copy, or an expired session would be served back offline as fact.
|
||||||
|
if (res.ok) cache.put(request, res.clone());
|
||||||
|
return res;
|
||||||
|
} catch (err) {
|
||||||
|
const hit = await cache.match(request);
|
||||||
|
if (hit) return hit;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigate(request) {
|
||||||
|
try {
|
||||||
|
return await fetch(request);
|
||||||
|
} catch {
|
||||||
|
const cache = await caches.open(SHELL);
|
||||||
|
const shell = await cache.match('/');
|
||||||
|
return shell ?? Response.error();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign-out asks for the data cache to be emptied: the next person on this browser must
|
||||||
|
// not find the last one's tasks sitting in it.
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
if (event.data?.type === 'clear-api-cache') {
|
||||||
|
event.waitUntil(caches.delete(API));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Web Push (unchanged in behaviour, moved here from the old static/sw.js) --------
|
||||||
|
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
let payload = {};
|
||||||
|
try {
|
||||||
|
payload = event.data ? event.data.json() : {};
|
||||||
|
} catch {
|
||||||
|
// A push whose body is not our JSON still deserves to be shown: browsers show
|
||||||
|
// their own "site updated in the background" notice otherwise, which looks like
|
||||||
|
// a bug in the app.
|
||||||
|
payload = { title: 'verdande', body: event.data ? event.data.text() : '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = payload.title || 'verdande';
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(title, {
|
||||||
|
body: payload.body || '',
|
||||||
|
icon: '/icon.svg',
|
||||||
|
badge: '/icon.svg',
|
||||||
|
// The tag collapses repeats: five comments on one task should replace each
|
||||||
|
// other rather than stack into five separate notifications.
|
||||||
|
tag: payload.tag || payload.url || 'verdande',
|
||||||
|
data: { url: payload.url || '/' }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const target = event.notification.data?.url || '/';
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const clientList = await self.clients.matchAll({
|
||||||
|
type: 'window',
|
||||||
|
includeUncontrolled: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus a tab that already has the app open rather than opening a second one.
|
||||||
|
// Somebody with the app pinned does not want a new tab per reminder.
|
||||||
|
for (const client of clientList) {
|
||||||
|
if (new URL(client.url).origin === self.location.origin) {
|
||||||
|
await client.focus();
|
||||||
|
if ('navigate' in client) await client.navigate(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await self.clients.openWindow(target);
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
@@ -1,9 +1,13 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
<!-- Wunjo, the rune Verdande's name begins with. Drawn as strokes rather than
|
<!-- Wunjo, the rune Verdande's name begins with. Drawn as strokes rather than
|
||||||
as a filled glyph so it stays legible at 16px in a browser tab. -->
|
as a filled glyph so it stays legible at 16px in a browser tab. Bigger and
|
||||||
|
heavier than it was, to read clearly as a home-screen icon, and nudged right
|
||||||
|
of the geometric centre so the heavy stave and the light flag balance about
|
||||||
|
the middle of the tile — a bounding box centred on 32 leaves the stave
|
||||||
|
looking left-hung. -->
|
||||||
<rect width="64" height="64" rx="14" fill="#111312"/>
|
<rect width="64" height="64" rx="14" fill="#111312"/>
|
||||||
<g fill="none" stroke="#5fbf8f" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round">
|
<g fill="none" stroke="#5fbf8f" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M22 14v36"/>
|
<path d="M26 11v42"/>
|
||||||
<path d="M22 17l17 8-17 8"/>
|
<path d="M26 15l17 9.5L26 34"/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 447 B After Width: | Height: | Size: 734 B |
@@ -1,64 +0,0 @@
|
|||||||
/**
|
|
||||||
* verdande's service worker.
|
|
||||||
*
|
|
||||||
* It exists for Web Push and nothing else. There is deliberately no offline cache:
|
|
||||||
* the app is a live view of a task list that other people are editing, and a cached
|
|
||||||
* shell serving yesterday's data is worse than a browser saying it cannot connect.
|
|
||||||
* Adding one later means versioning and invalidating it, which is a real feature
|
|
||||||
* rather than a few lines here.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Take over as soon as the browser is willing. Without these two, the first push
|
|
||||||
// after granting permission arrives with no worker in control to show it.
|
|
||||||
self.addEventListener('install', () => self.skipWaiting());
|
|
||||||
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
|
|
||||||
|
|
||||||
self.addEventListener('push', (event) => {
|
|
||||||
let payload = {};
|
|
||||||
try {
|
|
||||||
payload = event.data ? event.data.json() : {};
|
|
||||||
} catch {
|
|
||||||
// A push whose body is not our JSON still deserves to be shown: browsers
|
|
||||||
// display their own "site updated in the background" notice otherwise, which
|
|
||||||
// looks like a bug in the app.
|
|
||||||
payload = { title: 'verdande', body: event.data ? event.data.text() : '' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = payload.title || 'verdande';
|
|
||||||
event.waitUntil(
|
|
||||||
self.registration.showNotification(title, {
|
|
||||||
body: payload.body || '',
|
|
||||||
icon: '/icon.svg',
|
|
||||||
badge: '/icon.svg',
|
|
||||||
// The tag collapses repeats: five comments on one task should replace each
|
|
||||||
// other rather than stack into five separate notifications.
|
|
||||||
tag: payload.tag || payload.url || 'verdande',
|
|
||||||
data: { url: payload.url || '/' }
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener('notificationclick', (event) => {
|
|
||||||
event.notification.close();
|
|
||||||
const target = event.notification.data?.url || '/';
|
|
||||||
|
|
||||||
event.waitUntil(
|
|
||||||
(async () => {
|
|
||||||
const clientList = await self.clients.matchAll({
|
|
||||||
type: 'window',
|
|
||||||
includeUncontrolled: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Focus a tab that already has the app open rather than opening a second
|
|
||||||
// one. Somebody with the app pinned does not want a new tab per reminder.
|
|
||||||
for (const client of clientList) {
|
|
||||||
if (new URL(client.url).origin === self.location.origin) {
|
|
||||||
await client.focus();
|
|
||||||
if ('navigate' in client) await client.navigate(target);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await self.clients.openWindow(target);
|
|
||||||
})()
|
|
||||||
);
|
|
||||||
});
|
|
||||||