Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
1f9f14099b | ||
|
|
6cf63113df | ||
|
|
e24a0fa849 | ||
|
|
1c223a2c26 | ||
|
|
a9c90ee6f1 | ||
|
|
dc4a584d5f | ||
|
|
378980de0c | ||
|
|
04c7a52a5a | ||
|
|
52df528994 | ||
|
|
2f00246d6f |
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 331 KiB After Width: | Height: | Size: 334 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 284 KiB |
|
Before Width: | Height: | Size: 355 KiB After Width: | Height: | Size: 370 KiB |
|
Before Width: | Height: | Size: 340 KiB After Width: | Height: | Size: 355 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 227 KiB |
@@ -3,8 +3,10 @@ module github.com/kristianwind/verdande
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc
|
||||
github.com/coder/websocket v1.8.15
|
||||
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-webauthn/webauthn v0.17.4
|
||||
github.com/pquerna/otp v1.5.0
|
||||
@@ -14,9 +16,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // 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/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/boombuler/barcode/qr"
|
||||
"github.com/pquerna/otp"
|
||||
"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
|
||||
}
|
||||
|
||||
// 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
|
||||
// code again before enrolment is confirmed.
|
||||
func TOTPURI(secret, issuer, account string) string {
|
||||
|
||||
@@ -17,6 +17,9 @@ import (
|
||||
type totpSetupResponse struct {
|
||||
Secret string `json:"secret"`
|
||||
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) {
|
||||
@@ -54,7 +57,12 @@ func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "store totp secret", err)
|
||||
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 {
|
||||
|
||||
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 41 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: 43 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 65 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: 46 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 40 KiB |
@@ -371,9 +371,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').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
|
||||
// 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
|
||||
// turn up inside another control's name, and Playwright matches an accessible
|
||||
@@ -383,19 +383,20 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
||||
await expect(heading).toBeVisible();
|
||||
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
|
||||
|
||||
// A group's name must be readable in full, and must start where a project's
|
||||
// name starts.
|
||||
// A group's name must be readable in full, and must line up with the other
|
||||
// headings — not with the projects filed under it.
|
||||
//
|
||||
// Both halves are here because both were once broken at the same time and for
|
||||
// different reasons. "Omdøb" and "Slet" sat in the flow reserving their width
|
||||
// even while invisible, so ARBEJDE was drawn as "ARBE"; and the fold chevron
|
||||
// sat before the name, pushing a group 22px further in than the projects it
|
||||
// contains — so a group read as if it were filed under them. Every assertion
|
||||
// in this file passed through both, because both buttons still worked.
|
||||
// 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
|
||||
// as "ARBE". The alignment half changed meaning with the redesign: a group now
|
||||
// carries a section icon just like "Projekter" above it, and belongs to that
|
||||
// heading tier rather than to the project rows below.
|
||||
const shape = await sidebar.locator('.folder-head').evaluate((el) => {
|
||||
const link = el.querySelector('h2 a');
|
||||
const name = { ...link.querySelector('.dot').getBoundingClientRect().toJSON() };
|
||||
// The last child of the link is the name itself; the dot is a span before it.
|
||||
// The leading mark is the section icon in its own fold button, the same
|
||||
// folder/funnel/tag glyph the system headings carry — a group and a section
|
||||
// read identically, and the icon sits before the name, not inside the link.
|
||||
const name = { ...el.querySelector('.head-icon').getBoundingClientRect().toJSON() };
|
||||
return {
|
||||
clipped: link.scrollWidth > link.clientWidth + 1,
|
||||
left: name.left,
|
||||
@@ -407,17 +408,17 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
||||
expect(shape.clipped, 'gruppens navn bliver klippet').toBe(false);
|
||||
expect(shape.count, 'navnet og tallet overlapper').toBeGreaterThanOrEqual(shape.right);
|
||||
|
||||
// Measured dot to dot, not box to box: both rows begin with the same coloured
|
||||
// mark, and it is the only thing in them that means the same on both sides.
|
||||
// Comparing the group's inner link with the project's outer box reported the
|
||||
// row's own padding as a misalignment.
|
||||
const projectLeft = await sidebar
|
||||
.getByRole('link', { name: 'Regnskab' })
|
||||
.evaluate((el) => el.querySelector('.dot').getBoundingClientRect().left);
|
||||
// A group is a heading, not a project row: it belongs to the same tier as
|
||||
// "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
|
||||
// icon to icon, the one mark both headings share.
|
||||
const sectionLeft = await sidebar
|
||||
.getByRole('heading', { name: 'Projekter' })
|
||||
.evaluate((el) => el.querySelector('.head-icon').getBoundingClientRect().left);
|
||||
expect(
|
||||
Math.abs(shape.left - projectLeft),
|
||||
`gruppen starter ${Math.round(shape.left - projectLeft)}px fra projekterne`
|
||||
).toBeLessThanOrEqual(2);
|
||||
Math.abs(shape.left - sectionLeft),
|
||||
`gruppen starter ${Math.round(shape.left - sectionLeft)}px fra sektionsoverskrifterne`
|
||||
).toBeLessThanOrEqual(4);
|
||||
|
||||
// Dragged in, then dragged back out onto "Projekter" — which is the reason
|
||||
// that heading is a drop target at all: with every project filed away there
|
||||
@@ -440,18 +441,13 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
||||
'false'
|
||||
);
|
||||
|
||||
// En foldet gruppe står med sin chevron fremme permanent, og den lå oven i
|
||||
// prikken med et felt i --surface bag sig — en lys firkant ud for navnet på
|
||||
// den sænkede bjælke, hele tiden. Nu tones prikken ud i stedet, så der er
|
||||
// ingenting at male henover den med.
|
||||
const chevron = await sidebar
|
||||
.locator('.folder-head .chevron-button')
|
||||
.evaluate((el) => ({
|
||||
background: getComputedStyle(el).backgroundColor,
|
||||
dot: getComputedStyle(el.closest('.folder-head').querySelector('.group-dot')).opacity
|
||||
}));
|
||||
expect(chevron.background, 'chevronen har en firkant bag sig').toBe('rgba(0, 0, 0, 0)');
|
||||
expect(chevron.dot, 'prikken ligger stadig under chevronen').toBe('0');
|
||||
// The fold control is the bare folder icon now — no chevron, and no --surface
|
||||
// field behind it, so a folded group leaves no pale square beside its name on
|
||||
// the sunken bar.
|
||||
const foldBg = await sidebar
|
||||
.locator('.folder-head .fold-icon')
|
||||
.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||
expect(foldBg, 'foldeknappen har en firkant bag sig').toBe('rgba(0, 0, 0, 0)');
|
||||
|
||||
await sidebar.getByRole('button', { name: /^Fold Arbejde/ }).click();
|
||||
|
||||
@@ -467,11 +463,7 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
||||
// button on click.
|
||||
await expect(sidebar.getByLabel('Gruppens navn')).toBeVisible();
|
||||
await sidebar.getByLabel('Gruppens navn').press('Enter');
|
||||
|
||||
const painted = await sidebar
|
||||
.locator('.folder-head .group-dot')
|
||||
.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||
expect(painted, 'gruppens prik fik ikke sin farve').not.toBe('rgba(0, 0, 0, 0)');
|
||||
await expect(sidebar.getByRole('link', { name: 'Arbejde', exact: true })).toBeVisible();
|
||||
|
||||
// The heading itself is *not* indented — only what is filed under it. A heading
|
||||
// that starts further in than the rows above it says the heading is inside
|
||||
@@ -573,8 +565,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(page.locator('.toast')).toHaveCount(0);
|
||||
|
||||
// A load that fails says so, and offers to try again.
|
||||
await page.route('**/api/v1/projects/*', (r) => r.abort());
|
||||
// A load that fails with a server error says so at once and offers to try again.
|
||||
// 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 expect(page.getByText('Serveren svarede ikke')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Prøv igen' })).toBeVisible();
|
||||
@@ -585,6 +582,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([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -1525,9 +1568,18 @@ test('en gruppe er en side med sine projekter, en beskrivelse og filer', async (
|
||||
// Into the group, then onto its page — which is what the heading is a link to
|
||||
// now. The chevron beside it still folds; they are two intentions and the old
|
||||
// single target could only serve the smaller one.
|
||||
await sidebar
|
||||
.getByRole('link', { name: 'Drejebænk' })
|
||||
.dragTo(sidebar.getByRole('link', { name: 'Værksted' }));
|
||||
//
|
||||
// The drop is retried until the project actually nests under the group: a native
|
||||
// HTML5 drag occasionally does not register the first time under CI load, and a
|
||||
// single dragTo then leaves the group empty and the page assertion failing for a
|
||||
// reason that has nothing to do with what the test is about.
|
||||
const nested = sidebar.locator('.folder > a').filter({ hasText: 'Drejebænk' });
|
||||
await expect(async () => {
|
||||
await sidebar
|
||||
.getByRole('link', { name: 'Drejebænk' })
|
||||
.dragTo(sidebar.getByRole('link', { name: 'Værksted' }));
|
||||
await expect(nested).toHaveCount(1, { timeout: 1000 });
|
||||
}).toPass({ timeout: 10000 });
|
||||
await sidebar.getByRole('link', { name: 'Værksted' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Værksted', level: 1 })).toBeVisible();
|
||||
@@ -1978,7 +2030,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
|
||||
// account its key belongs to, which is also why this page cannot be used to
|
||||
// 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
|
||||
// Playwright matches an accessible name loosely unless told otherwise.
|
||||
await expect(page.getByRole('button', { name: 'Log ind', exact: true })).toBeVisible();
|
||||
|
||||
@@ -210,6 +210,52 @@
|
||||
--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. */
|
||||
[data-theme='paper'] {
|
||||
--ground: #f6f1e7;
|
||||
@@ -375,9 +421,13 @@ button {
|
||||
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 {
|
||||
background: var(--accent-sunken);
|
||||
color: var(--ink);
|
||||
background: color-mix(in oklab, var(--accent) 42%, transparent);
|
||||
}
|
||||
|
||||
/* 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 { 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.
|
||||
@@ -110,14 +161,10 @@ async function request(method, path, body, options = {}) {
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`/api/v1${path}`, init);
|
||||
} catch (e) {
|
||||
// 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');
|
||||
}
|
||||
// Retries a network failure for a few seconds so a short drop does not lose the
|
||||
// call; `withRetry` turns an exhausted window into the same `offline` error the
|
||||
// app already handles. `options.noRetry` opts a call out — used by nothing yet.
|
||||
const res = await withRetry(() => fetch(`/api/v1${path}`, init), options.noRetry);
|
||||
|
||||
if (res.status === 204) return null;
|
||||
|
||||
@@ -157,12 +204,9 @@ async function upload(path, file, field = 'file') {
|
||||
const form = new FormData();
|
||||
form.append(field, file);
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`/api/v1${path}`, { method: 'POST', body: form, credentials: 'same-origin' });
|
||||
} catch {
|
||||
throw new ApiError(0, 'offline', 'ingen forbindelse');
|
||||
}
|
||||
const res = await withRetry(() =>
|
||||
fetch(`/api/v1${path}`, { method: 'POST', body: form, credentials: 'same-origin' })
|
||||
);
|
||||
|
||||
const text = await res.text();
|
||||
let payload = null;
|
||||
|
||||
@@ -651,6 +651,7 @@
|
||||
<div
|
||||
class="allday"
|
||||
data-allday={iso(date)}
|
||||
class:today={iso(date) === todayISO}
|
||||
class:over={over === iso(date)}
|
||||
ondragover={(e) => onDragOver(e, date)}
|
||||
ondragleave={() => (over = null)}
|
||||
@@ -876,12 +877,16 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* White, not the sidebar's sunken ground: a calendar reads lightest when the
|
||||
day itself is the brightest thing on the screen and the events are the soft
|
||||
colour laid on it — the opposite of the grey grid it was, where the events
|
||||
had to shout to be seen at all. */
|
||||
.corner,
|
||||
.head,
|
||||
.gutter,
|
||||
.allday,
|
||||
.daycol {
|
||||
background: var(--ground);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.head {
|
||||
@@ -889,6 +894,16 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Today's whole column, faintly, so the eye finds it before it reads a number —
|
||||
the marker on the date above is the label, this is the glow it sits in. Kept
|
||||
under the events, so a pastel block still reads as its own calendar's colour
|
||||
rather than as this wash tinted. */
|
||||
.head.today,
|
||||
.allday.today,
|
||||
.daycol.today {
|
||||
background: color-mix(in oklab, var(--accent) 6%, var(--surface));
|
||||
}
|
||||
|
||||
.head .wd {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
@@ -901,9 +916,19 @@
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* The marker at the top the day is named by: today's number in a filled disc,
|
||||
the same one the month grid gives today, so "this is today" is one glance in
|
||||
either view rather than a colour you have to already know to read. */
|
||||
.head.today .num {
|
||||
color: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
font-weight: 560;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.gutter {
|
||||
@@ -977,6 +1002,10 @@
|
||||
/* Placeringen kommer fra `place()` som en inline `style`; alt andet står her.
|
||||
To ting på samme tid deler bredden, så `margin-right` er den ene hårstrå
|
||||
luft, der gør to kasser til to kasser. */
|
||||
/* A soft wash of the calendar's own colour rather than a grey block edged in it:
|
||||
each calendar keeps a dusty, unmistakable nuance, and a screen of them reads
|
||||
as colour laid on white instead of a grid of grey bars. The left edge stays,
|
||||
a shade stronger than the fill, so the block still has a spine. */
|
||||
.tevent {
|
||||
position: absolute;
|
||||
margin-right: 2px;
|
||||
@@ -984,16 +1013,20 @@
|
||||
padding: 1px var(--s1);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 3px solid var(--event-colour, var(--line-strong));
|
||||
background: var(--surface-sunken);
|
||||
color: var(--ink-muted);
|
||||
background: color-mix(in oklab, var(--event-colour, var(--line-strong)) 16%, var(--surface));
|
||||
color: var(--ink);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.3;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* A task is a card you can pick up, not an event you cannot — so on the white
|
||||
column it is an outlined card, where an event is a solid wash. The priority
|
||||
colour rides the left edge, as it does everywhere else. */
|
||||
.tevent.task {
|
||||
background: var(--surface-raised);
|
||||
background: var(--surface);
|
||||
box-shadow: inset 0 0 0 1px var(--line);
|
||||
border-left-color: var(--line-strong);
|
||||
color: var(--ink);
|
||||
cursor: grab;
|
||||
@@ -1101,7 +1134,8 @@
|
||||
text-align: left;
|
||||
padding: 1px var(--s1);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-raised);
|
||||
background: var(--surface);
|
||||
box-shadow: inset 0 0 0 1px var(--line);
|
||||
border-left: 2px solid var(--line-strong);
|
||||
color: var(--ink);
|
||||
overflow: hidden;
|
||||
@@ -1120,7 +1154,7 @@
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
background: var(--surface);
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
/* An event reads as a different kind of thing at a glance, before any of the
|
||||
@@ -1140,8 +1174,8 @@
|
||||
padding: 1px var(--s1);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 3px solid var(--event-colour);
|
||||
background: var(--surface-sunken);
|
||||
color: var(--ink-muted);
|
||||
background: color-mix(in oklab, var(--event-colour) 16%, var(--surface));
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -1156,7 +1190,7 @@
|
||||
}
|
||||
|
||||
.event:hover {
|
||||
background: var(--surface);
|
||||
background: color-mix(in oklab, var(--event-colour) 26%, var(--surface));
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
@@ -1165,7 +1199,7 @@
|
||||
every calendar anybody has used. */
|
||||
.event.allday {
|
||||
border-left: none;
|
||||
background: color-mix(in oklab, var(--event-colour) 22%, var(--ground));
|
||||
background: color-mix(in oklab, var(--event-colour) 22%, var(--surface));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--event-colour) 45%, transparent);
|
||||
}
|
||||
|
||||
@@ -1267,20 +1301,18 @@
|
||||
padding: 1px var(--s1);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 2px solid var(--line-strong);
|
||||
background: var(--surface-raised);
|
||||
background: var(--surface);
|
||||
box-shadow: inset 0 0 0 1px var(--line);
|
||||
}
|
||||
|
||||
.calendar.week .chip[data-priority='1'] {
|
||||
border-left-color: var(--p1);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
.calendar.week .chip[data-priority='2'] {
|
||||
border-left-color: var(--p2);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
.calendar.week .chip[data-priority='3'] {
|
||||
border-left-color: var(--p3);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
/* Readable again in the week, for the reason the task chips are: a
|
||||
@@ -1292,12 +1324,12 @@
|
||||
padding: 1px var(--s1);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 3px solid var(--event-colour);
|
||||
background: var(--surface-sunken);
|
||||
background: color-mix(in oklab, var(--event-colour) 16%, var(--surface));
|
||||
}
|
||||
|
||||
.calendar.week .event.allday {
|
||||
border-left: none;
|
||||
background: color-mix(in oklab, var(--event-colour) 22%, var(--ground));
|
||||
background: color-mix(in oklab, var(--event-colour) 22%, var(--surface));
|
||||
}
|
||||
|
||||
.calendar.week .at {
|
||||
|
||||
@@ -34,6 +34,23 @@
|
||||
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 labels = $state([]);
|
||||
|
||||
@@ -487,6 +504,7 @@
|
||||
it, which is the second time that drop has earned its test. -->
|
||||
<a
|
||||
href={item.href}
|
||||
data-view={item.key}
|
||||
class:active={current === item.href}
|
||||
class:receiving={item.key === 'today' && overToday}
|
||||
onclick={onnavigate}
|
||||
@@ -616,6 +634,7 @@
|
||||
{#snippet projectRow(project, sortable)}
|
||||
<a
|
||||
href="/projekt/{project.id}"
|
||||
data-view={project.is_inbox ? 'inbox' : undefined}
|
||||
class:sortable
|
||||
class:active={current === `/projekt/${project.id}`}
|
||||
class:dragging={draggingId === project.id}
|
||||
@@ -680,28 +699,84 @@
|
||||
<!--
|
||||
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
|
||||
the proof that they drift otherwise — it grew a fold, a colour and two
|
||||
actions while these stayed plain text.
|
||||
|
||||
The whole heading is the target, not the chevron. At this size a lone chevron
|
||||
is something you miss, and the name is where the eye already is.
|
||||
The whole heading is the target, and the icon is the mark: no arrow beside it.
|
||||
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
|
||||
same kind of thing — a folder of projects, a funnel of filters, a tag of
|
||||
labels — instead of one being a faint label and the other a coloured dot. -->
|
||||
{#snippet sectionIcon(key)}
|
||||
{#if key === 'filters'}
|
||||
<svg class="head-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
d="M2.2 3.4h11.6L9.3 8.8v3.4L6.7 10.9V8.8z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{:else if key === 'labels'}
|
||||
<svg class="head-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
d="M7.7 2.3H13v5.3l-5.3 5.3-5.3-5.3z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="10.2" cy="4.9" r="0.95" fill="currentColor" />
|
||||
</svg>
|
||||
{:else if key === 'shared'}
|
||||
<svg class="head-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<circle cx="6.2" cy="5.6" r="2.1" fill="none" stroke="currentColor" stroke-width="1.4" />
|
||||
<path
|
||||
d="M2.6 12.8c0-2.1 1.7-3.3 3.6-3.3s3.6 1.2 3.6 3.3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M11 9.6c1.5.2 2.6 1.3 2.6 3.2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<circle cx="11.2" cy="5.6" r="1.7" fill="none" stroke="currentColor" stroke-width="1.4" />
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Projekter and every group: a folder of projects. -->
|
||||
<svg class="head-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>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#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">
|
||||
<button
|
||||
onclick={() => app.toggleSection(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)}
|
||||
{label}
|
||||
</button>
|
||||
</h2>
|
||||
@@ -724,7 +799,7 @@
|
||||
and with every project filed there would be no loose row left to aim at. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="group-head"
|
||||
class="group-head sticky-head"
|
||||
class:over={overGroup === ''}
|
||||
ondragover={(e) => {
|
||||
if (!carries(e, PROJECT)) return;
|
||||
@@ -869,31 +944,22 @@
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<!-- The button sits inside the heading, not around it, the same way a
|
||||
project's title does: a group *is* a heading in the sidebar, and
|
||||
wrapping an h2 in a button both invalidates the markup and takes
|
||||
the heading away from a screen reader. The whole heading folds —
|
||||
at this size a lone chevron is a target you miss, and the name is
|
||||
where the eye already is. -->
|
||||
<!-- The chevron folds; the name opens the group's own page. Two
|
||||
targets in one heading, because they are two different intentions
|
||||
and the old single one could only ever serve the smaller. -->
|
||||
<!-- No chevron: the folder icon itself folds the group, and the name
|
||||
beside it opens the group's own page. Two intentions, two targets,
|
||||
but no arrow to clutter the rail — the icon a section carries is
|
||||
the same one that folds it here. The button sits inside the
|
||||
heading, not around it: a group *is* a heading, and wrapping an h2
|
||||
in a button both invalidates the markup and takes the heading from
|
||||
a screen reader. -->
|
||||
<button
|
||||
class="chevron-button"
|
||||
class="fold-icon"
|
||||
onclick={() => app.toggleGroup(group.id)}
|
||||
aria-expanded={!group.collapsed}
|
||||
aria-label={group.collapsed
|
||||
? t('sidebar.unfold', { name: group.name })
|
||||
: t('sidebar.fold', { name: group.name })}
|
||||
>
|
||||
<svg
|
||||
class="chevron"
|
||||
class:collapsed={group.collapsed}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
{@render sectionIcon('group')}
|
||||
</button>
|
||||
<h2 class="fold">
|
||||
<!-- Navigation waits two tenths of a second for a second click. A
|
||||
@@ -914,11 +980,6 @@
|
||||
}, 200);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="dot group-dot"
|
||||
style="background: {colorVar(group.color)}"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{group.name}
|
||||
</a>
|
||||
</h2>
|
||||
@@ -1006,12 +1067,12 @@
|
||||
{#if !app.connected}
|
||||
<span class="offline" title={t('nav.offlineHint')}>{t('nav.offline')}</span>
|
||||
{/if}
|
||||
<button class="user" onclick={signOut}>
|
||||
<button class="user" class:confirming={confirmSignOut} onclick={onSignOutClick}>
|
||||
<span class="avatar" style="background: {app.user?.avatar_color}">
|
||||
{app.user?.name?.[0]?.toUpperCase() ?? '?'}
|
||||
</span>
|
||||
<span class="user-name">{app.user?.name}</span>
|
||||
<span class="signout">{t('nav.signOut')}</span>
|
||||
<span class="user-name">{confirmSignOut ? t('nav.signOutConfirm') : app.user?.name}</span>
|
||||
<span class="signout">{confirmSignOut ? '' : t('nav.signOut')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1047,14 +1108,16 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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);
|
||||
border-right: 1px solid var(--line);
|
||||
/* 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,
|
||||
skal den klippes her og ikke skubbe kolonnen bredere. */
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1073,6 +1136,32 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s5);
|
||||
/* The sidebar is padded, so the scrollbar sat a pad-width inside the edge and
|
||||
read as floating in the middle. Pull the scroller out to the border and pad
|
||||
the content back, so the bar sits against the edge where a scrollbar
|
||||
belongs — the rows do not move. Thin and dim, so it is present without
|
||||
drawing the eye. */
|
||||
margin-right: calc(var(--s3) * -1);
|
||||
padding-right: var(--s3);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--line-strong) transparent;
|
||||
}
|
||||
|
||||
.scroller::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
/* No transparent border and no padding-box clip: those centred a 4px bar in a
|
||||
10px track, so the thumb floated three pixels shy of the divider. The bar is
|
||||
the track now — a thin rounded thumb sitting flush against the line on the
|
||||
right, which is where a scrollbar belongs. */
|
||||
.scroller::-webkit-scrollbar-thumb {
|
||||
background: var(--line-strong);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.scroller::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Wider than it looks: a 1px target is a target you miss. The visible line stays
|
||||
@@ -1201,8 +1290,26 @@
|
||||
flex: none;
|
||||
}
|
||||
|
||||
a.active .mark {
|
||||
color: var(--accent);
|
||||
/* The fixed views carry a colour of their own, the way Things does — the star
|
||||
amber, the Inbox blue — and keep it whether or not they are the active row.
|
||||
So no active-state override here; the colour is the icon's identity. */
|
||||
[data-view='inbox'] .mark {
|
||||
color: var(--color-blue);
|
||||
}
|
||||
[data-view='today'] .mark {
|
||||
color: var(--color-amber);
|
||||
}
|
||||
[data-view='upcoming'] .mark {
|
||||
color: var(--color-tomato);
|
||||
}
|
||||
[data-view='delegated'] .mark {
|
||||
color: var(--color-violet);
|
||||
}
|
||||
[data-view='notes'] .mark {
|
||||
color: var(--color-teal);
|
||||
}
|
||||
[data-view='calendar'] .mark {
|
||||
color: var(--color-indigo);
|
||||
}
|
||||
|
||||
.group-head {
|
||||
@@ -1212,12 +1319,35 @@
|
||||
padding: 0 var(--s2) var(--s2);
|
||||
}
|
||||
|
||||
/* The Projekter heading holds the only way to add a project or a group, so it
|
||||
must not scroll away with a long list. Pinned to the top of the scroller, on
|
||||
the sidebar's own ground so the rows pass under it. */
|
||||
.sticky-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
padding-top: var(--s2);
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
/* Every heading — the system sections and the user's groups — reads the same:
|
||||
a mark, then a bold plainly-cased name. What used to set them apart (one a
|
||||
faint uppercase label, the other a bold name with a coloured dot) is gone. */
|
||||
h2 {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 560;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--menu-size);
|
||||
font-weight: 600;
|
||||
/* 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
|
||||
nudged like a row's own mark, so the icons line up down the sidebar. */
|
||||
.head-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: none;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
/* Only the square icon buttons — "+" and the folder — and asked for by class
|
||||
@@ -1303,57 +1433,17 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The chevron on its own, so the name beside it can be a link to the group's
|
||||
page. Sized as a target rather than as a glyph: 11px of arrow is something
|
||||
you miss. */
|
||||
/* The chevron sits on top of the group's dot rather than before it. In the flow
|
||||
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. */
|
||||
.chevron-button {
|
||||
position: absolute;
|
||||
left: calc(var(--s2) - 5px);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
/* The folder icon is the fold toggle now — no chevron beside it. A bare icon
|
||||
button the size of the icon it holds: the name next to it opens the group's
|
||||
page, this folds it, and neither wears an arrow to say so. */
|
||||
.fold-icon {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-sm);
|
||||
color: inherit;
|
||||
opacity: 0;
|
||||
/* Fordi prikken herunder tones ud frem for at blive dækket. En `opacity`
|
||||
under 1 laver en stakkontekst og maler elementet, som var det placeret
|
||||
med `z-index: 0` — så prikken, der står senere i DOM'en, lagde sig oven
|
||||
på knappen i det øjeblik den forsvandt. Den var usynlig og tog stadig
|
||||
klikket: prøven brugte tredive sekunder på at folde en gruppe, der ikke
|
||||
ville foldes. Ét lag op, og rækkefølgen er den samme som før. */
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.folder-head:hover .chevron-button,
|
||||
.chevron-button:focus-visible,
|
||||
.chevron-button[aria-expanded='false'] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Præcis de tre tilfælde ovenfor, læst nedad i stedet for opad: står chevronen
|
||||
der, er prikken væk. `opacity` og ikke `display`, for de to ligger oven i
|
||||
hinanden — forsvandt prikken ud af flowet, ville navnet rykke sig hver gang
|
||||
pilen kom frem. */
|
||||
.folder-head:hover .group-dot,
|
||||
.folder-head:has(.chevron-button:focus-visible) .group-dot,
|
||||
.folder-head:has(.chevron-button[aria-expanded='false']) .group-dot {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.chevron-button:hover {
|
||||
.fold-icon:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
@@ -1382,22 +1472,6 @@
|
||||
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
|
||||
page: two buttons permanently beside a label stop it reading as a label. */
|
||||
.folder-head .count {
|
||||
@@ -1430,14 +1504,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The group's own mark, between the chevron and the name — inside the fold
|
||||
button, so it cannot push the heading in. It is a mark now and not a
|
||||
control: the colour is chosen while renaming, which is the other edit to
|
||||
the same thing. */
|
||||
.group-dot {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Projects sit at the same left margin whether or not they are in an area —
|
||||
one straight edge down the whole sidebar reads calmer than a staircase, and
|
||||
the bold area heading above already does the grouping the indent used to. */
|
||||
@@ -1453,18 +1519,6 @@
|
||||
padding-left: var(--s2);
|
||||
}
|
||||
|
||||
/* An area is the sidebar's own heading — Things treats it as bold and plainly
|
||||
cased, a thing the eye lands on, not another faint uppercase label like the
|
||||
system sections. So an area's name is set apart while "Projekter", "Filtre"
|
||||
and "Etiketter" stay quiet. */
|
||||
.folder-head h2 {
|
||||
font-size: var(--menu-size);
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Inside the rename form now, so it needs no padding of its own — the form
|
||||
already sits where the heading did. */
|
||||
.renaming {
|
||||
@@ -1533,9 +1587,16 @@
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* An accent-tinted selection, not a half-step of surface: on the sunken dark
|
||||
sidebar --surface-raised was all but invisible, so the active row read as no
|
||||
different from the rest. --accent-sunken carries the accent hue and stands out
|
||||
in both themes. */
|
||||
a.active {
|
||||
background: var(--surface-raised);
|
||||
color: var(--ink);
|
||||
background: var(--accent-sunken);
|
||||
/* 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;
|
||||
}
|
||||
|
||||
@@ -1658,6 +1719,16 @@
|
||||
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 {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
|
||||
@@ -228,13 +228,21 @@
|
||||
align-items: flex-start;
|
||||
padding: var(--s3) var(--s2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
transition:
|
||||
opacity var(--medium) var(--ease),
|
||||
transform var(--medium) var(--ease);
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--surface);
|
||||
/* Hover only where there is a real pointer. On a touch screen a :hover on the tap
|
||||
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
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Connectivity, as the app experiences it — not as the browser reports it.
|
||||
*
|
||||
* This is for short drops: a few seconds of lost Wi-Fi, a tunnel, a base station
|
||||
* handing over. It is deliberately NOT offline support. The app is already loaded
|
||||
* and its data is already in memory, so there is nothing to cache and nothing to
|
||||
* open from cold; what a blip needs is for the save you just made to survive it,
|
||||
* not for the whole app to run without a server.
|
||||
*
|
||||
* 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.settings': 'Indstillinger',
|
||||
'nav.signOut': 'Log ud',
|
||||
'nav.signOutConfirm': 'Tryk igen for at logge ud',
|
||||
'nav.main': 'Hovedmenu',
|
||||
'nav.search': 'Søg',
|
||||
'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.offline': 'Offline',
|
||||
'nav.offlineHint': 'Ændringer fra andre vises ikke lige nu',
|
||||
'net.reconnecting': 'Genopretter forbindelse…',
|
||||
'net.offline': 'Ingen forbindelse',
|
||||
'nav.doItToday': 'Gør det i dag',
|
||||
|
||||
// --- the sidebar's own sections ------------------------------------------------
|
||||
@@ -841,6 +844,8 @@ export const da = {
|
||||
// --- the themes -------------------------------------------------------------------
|
||||
'theme.dark': 'Nordlys',
|
||||
'theme.darkNote': 'Mørk med grøn accent.',
|
||||
'theme.charcoal': 'Kul',
|
||||
'theme.charcoalNote': 'Neutral mørkegrå.',
|
||||
'theme.dusk': 'Skumring',
|
||||
'theme.duskNote': 'Mørk og varm, med rav.',
|
||||
'theme.light': 'Dagslys',
|
||||
|
||||
@@ -115,6 +115,7 @@ export const en = {
|
||||
'nav.calendar': 'Calendar',
|
||||
'nav.settings': 'Settings',
|
||||
'nav.signOut': 'Sign out',
|
||||
'nav.signOutConfirm': 'Click again to sign out',
|
||||
'nav.main': 'Main menu',
|
||||
'nav.search': 'Search',
|
||||
'nav.searchLong': 'Search tasks and projects',
|
||||
@@ -126,6 +127,8 @@ export const en = {
|
||||
'nav.toggleTheme': 'Switch between light and dark',
|
||||
'nav.offline': 'Offline',
|
||||
'nav.offlineHint': 'Changes from other people are not showing right now',
|
||||
'net.reconnecting': 'Reconnecting…',
|
||||
'net.offline': 'No connection',
|
||||
'nav.doItToday': 'Do it today',
|
||||
|
||||
// --- the sidebar's own sections ------------------------------------------------
|
||||
@@ -834,6 +837,8 @@ export const en = {
|
||||
// --- the themes -------------------------------------------------------------------
|
||||
'theme.dark': 'Northern lights',
|
||||
'theme.darkNote': 'Dark, with a green accent.',
|
||||
'theme.charcoal': 'Charcoal',
|
||||
'theme.charcoalNote': 'Neutral dark grey.',
|
||||
'theme.dusk': 'Dusk',
|
||||
'theme.duskNote': 'Dark and warm, with amber.',
|
||||
'theme.light': 'Daylight',
|
||||
|
||||
@@ -1006,6 +1006,7 @@ export const completedView = new CompletedView();
|
||||
*/
|
||||
export const THEMES = [
|
||||
{ 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: 'light', name: 'theme.light', note: 'theme.lightNote', dark: false },
|
||||
{ id: 'paper', name: 'theme.paper', note: 'theme.paperNote', dark: false },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import '../app.css';
|
||||
import { app, theme, sidebar } from '$lib/stores.svelte.js';
|
||||
import { connectivity } from '$lib/connectivity.svelte.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
@@ -14,6 +15,29 @@
|
||||
let paletteOpen = $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.
|
||||
*
|
||||
@@ -204,6 +228,16 @@
|
||||
</div>
|
||||
{/each}
|
||||
</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}
|
||||
|
||||
<style>
|
||||
@@ -378,6 +412,62 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
|
||||
let totpSecret = $state('');
|
||||
let totpURI = $state('');
|
||||
let totpQR = $state('');
|
||||
let totpCode = $state('');
|
||||
let totpErrors = $state({});
|
||||
let recoveryCodes = $state([]);
|
||||
@@ -163,6 +164,7 @@
|
||||
enablePassword = '';
|
||||
totpSecret = r.secret;
|
||||
totpURI = r.uri;
|
||||
totpQR = r.qr;
|
||||
} catch (e) {
|
||||
totpErrors = e.fields ?? {};
|
||||
if (!Object.keys(totpErrors).length) app.toast(humanMessage(e));
|
||||
@@ -177,6 +179,7 @@
|
||||
recoveryCodes = r.recovery_codes;
|
||||
totpSecret = '';
|
||||
totpURI = '';
|
||||
totpQR = '';
|
||||
totpCode = '';
|
||||
app.user = await api.me();
|
||||
} catch (e) {
|
||||
@@ -492,8 +495,13 @@
|
||||
{:else if totpSecret}
|
||||
<div class="field">
|
||||
<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="hint mono uri">{totpURI}</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={confirmTOTP}>
|
||||
@@ -514,7 +522,14 @@
|
||||
|
||||
<div class="row">
|
||||
<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>
|
||||
</form>
|
||||
{:else}
|
||||
@@ -859,6 +874,23 @@
|
||||
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 {
|
||||
margin: 0;
|
||||
padding: var(--s3);
|
||||
@@ -869,10 +901,6 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.uri {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
.bmc {
|
||||
|
||||
@@ -285,7 +285,7 @@
|
||||
<p class="hint">{t('data.doneHint')}</p>
|
||||
</header>
|
||||
<div class="row">
|
||||
<a class="ghost" href="/faerdige">{t('data.openDone')}</a>
|
||||
<a class="link" href="/faerdige">{t('data.openDone')}</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -132,7 +132,10 @@
|
||||
if (days === 1) return { key: 'i-gaar', label: t('notes.groupYesterday') };
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<div class="head">
|
||||
<h1>{t('notes.title')}</h1>
|
||||
@@ -810,7 +816,11 @@
|
||||
</svg>
|
||||
</span>
|
||||
{: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}
|
||||
{group.label}
|
||||
<span class="count">{group.notes.length}</span>
|
||||
@@ -831,41 +841,38 @@
|
||||
ondragstart={(e) => onDragStart(e, note)}
|
||||
ondragend={() => (dragging = null)}
|
||||
>
|
||||
<!-- Titel og projekt på linje ét, uddrag på linje to. Datoen står
|
||||
i gruppens overskrift og ikke her — undtagen når der er sorteret
|
||||
på navn, for så er der ingen overskrift, der siger den, og så er
|
||||
den det eneste tilbage, der gør. -->
|
||||
<!-- Tre linjer, som Apple Noter: titlen, så datoen og uddraget, og
|
||||
nederst hvor noten er lagt. Datoen står forrest på linje to og
|
||||
venstrestillet, ikke ude til højre. -->
|
||||
<span class="head">
|
||||
<strong>{note.title || t('notes.untitled')}</strong>
|
||||
{#if order === 'title'}
|
||||
<span class="when" title={stamp(note)}>{when(shownDate(note))}</span>
|
||||
{/if}
|
||||
{#if note.project_id}
|
||||
<!-- Projektets egen farve, den samme som prikken i sidebjælken.
|
||||
Mærkaten sagde det samme som alle de andre i en grå, der var
|
||||
lysere end uddraget — og projektet er dét, man skanner efter,
|
||||
når man leder i tolv hundrede noter. Nu kan Hjem kendes fra
|
||||
Claude uden at læse ordet. -->
|
||||
<span class="filed" style="--project: {projectColour(note.project_id)}">
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{projectName(note.project_id)}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="preview">
|
||||
<span class="when" title={stamp(note)}>{when(shownDate(note))}</span>
|
||||
<span class="snippet">{preview(note)}</span>
|
||||
</span>
|
||||
<!-- Linje tre: hvor noten er lagt, med den samme mappe som grupperne i
|
||||
menuen — projektets navn, ejerens hvis den er en andens, ellers bare
|
||||
"Noter", når den ikke hører til et projekt. -->
|
||||
<span class="filed">
|
||||
{#if note.shared_with_me && note.owner}
|
||||
<!-- Hvis note delt med mig: hvem den kom fra, i personens egen
|
||||
avatar-farve. Samme form som projekt-mærkaten, så listen har
|
||||
ét sprog for "det her hører til nogen". -->
|
||||
<span
|
||||
class="filed person"
|
||||
style="--project: {note.owner.avatar_color}"
|
||||
title={t('notes.sharedBy', { name: note.owner.name })}
|
||||
>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{note.owner.name}
|
||||
</span>
|
||||
<svg class="filed-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<circle cx="8" cy="6" r="2.3" fill="none" stroke="currentColor" stroke-width="1.4" />
|
||||
<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" />
|
||||
</svg>
|
||||
<span class="filed-name" title={t('notes.sharedBy', { name: note.owner.name })}>{note.owner.name}</span>
|
||||
{:else if note.project_id}
|
||||
<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">{projectName(note.project_id)}</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}
|
||||
</span>
|
||||
<span class="preview">{preview(note)}</span>
|
||||
</button>
|
||||
<!-- De to knapper står oven på hinanden i en smal søjle.
|
||||
Ved siden af hinanden tog de toogfyrre pixels af en liste,
|
||||
@@ -912,7 +919,15 @@
|
||||
</aside>
|
||||
|
||||
<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
|
||||
note={selected}
|
||||
notes={notes}
|
||||
@@ -1046,6 +1061,12 @@
|
||||
{/if}
|
||||
</div>
|
||||
</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}
|
||||
<p class="empty">{t('notes.pickOne')}</p>
|
||||
{/if}
|
||||
@@ -1064,8 +1085,9 @@
|
||||
against the sidebar's rule, which reads as a rendering fault rather than
|
||||
as a layout. */
|
||||
/* Luft hele vejen rundt om arket. Højre og bund var nul, dengang editoren
|
||||
ingen kant havde og ingenting kunne støde imod. */
|
||||
padding: var(--s3) var(--s4) var(--s3) var(--s4);
|
||||
ingen kant havde og ingenting kunne støde imod. Et par pixels mere om
|
||||
notelisten, så kortet ikke ligger klods op ad menuen. */
|
||||
padding: var(--s3) var(--s4) var(--s3) calc(var(--s4) + 3px);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 300px) 1fr;
|
||||
gap: var(--s4);
|
||||
@@ -1079,8 +1101,15 @@
|
||||
gap: var(--s2);
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
padding-right: var(--s3);
|
||||
/* A rounded card, the same corner and border as the editor beside it — the
|
||||
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
|
||||
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. */
|
||||
background: var(--surface-raised);
|
||||
padding: var(--s3);
|
||||
}
|
||||
|
||||
.head {
|
||||
@@ -1317,13 +1346,20 @@
|
||||
* målt på en rigtig række: titel plus én linje uddrag. */
|
||||
li {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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
|
||||
@@ -1362,43 +1398,51 @@
|
||||
*
|
||||
* Baggrunden er ikke gennemsigtig. En klæbende overskrift uden en er en
|
||||
* 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 {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
margin: 0;
|
||||
background: var(--ground);
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin: 0 0 var(--s1);
|
||||
/* --ground everywhere but Kul, where the ground is the darkest thing on screen
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
width: 100%;
|
||||
padding: var(--s2) var(--s2);
|
||||
font-size: var(--text-xs);
|
||||
font-size: var(--menu-size);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
color: var(--ink);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
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 {
|
||||
flex: none;
|
||||
width: 0.75em;
|
||||
font-size: 0.85em;
|
||||
line-height: 1;
|
||||
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
|
||||
@@ -1414,6 +1458,7 @@
|
||||
margin-left: auto;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
/* Stregen mellem rækkerne er væk.
|
||||
@@ -1450,13 +1495,38 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--surface);
|
||||
/* Pointer only. On a touch screen a :hover on the tap target costs a tap — iOS
|
||||
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 {
|
||||
background: var(--surface-raised);
|
||||
color: var(--ink);
|
||||
background: var(--note-on-bg, var(--surface-raised));
|
||||
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
|
||||
@@ -1475,19 +1545,25 @@
|
||||
/* 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. */
|
||||
|
||||
/* 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 {
|
||||
flex: none;
|
||||
color: var(--ink-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* `display: block`, og det er ikke pynt.
|
||||
*
|
||||
* Uddraget lå før inde i en flex-boks, der ejede afkortningen. Da datoen flyttede
|
||||
* 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. */
|
||||
/* 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
|
||||
`overflow: hidden`. */
|
||||
.preview {
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.45em;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.snippet {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -1504,31 +1580,30 @@
|
||||
* 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
|
||||
* 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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375em;
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
max-width: 45%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
gap: 0.4em;
|
||||
min-width: 0;
|
||||
margin-top: 1px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.filed .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
.filed-icon {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
flex: none;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--project, var(--line-strong));
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.filed::before {
|
||||
content: '▸ ';
|
||||
opacity: 0.7;
|
||||
.filed-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Noten er et ark, ikke en flade i appen.
|
||||
@@ -1546,7 +1621,11 @@
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
background: var(--surface);
|
||||
/* 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. Kul gives
|
||||
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-radius: var(--radius);
|
||||
/* Luft nok til at teksten ikke rører kanten.
|
||||
@@ -1746,17 +1825,6 @@
|
||||
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 {
|
||||
font-size: var(--text-xs);
|
||||
@@ -1876,14 +1944,48 @@
|
||||
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) {
|
||||
.notes {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
aside {
|
||||
border-right: 0;
|
||||
padding-right: 0;
|
||||
/* One pane at a time. The two-column card layout has no room on a phone, so
|
||||
the list shows until a note is opened and the editor takes over once one is
|
||||
— 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>
|
||||
|
||||
|
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">
|
||||
<!-- 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"/>
|
||||
<g fill="none" stroke="#5fbf8f" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 14v36"/>
|
||||
<path d="M22 17l17 8-17 8"/>
|
||||
<g fill="none" stroke="#5fbf8f" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M26 11v42"/>
|
||||
<path d="M26 15l17 9.5L26 34"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 447 B After Width: | Height: | Size: 734 B |