From 6cf184caa663cbd69438f5542a81679f68972a9c Mon Sep 17 00:00:00 2001 From: kristianwind Date: Wed, 9 Sep 2026 10:22:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(plan):=20dagens=20plan=20st=C3=A5r=20p?= =?UTF-8?q?=C3=A5=20I=20dag,=20og=20ikke=20kun=20i=20en=20besked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Planen var en notifikation og ikke andet. Klokken klippede den af efter én linje — "2 forfalder i dag, 1 er sprun…" — og klikket førte ingen steder hen, fordi en plan hverken er en note, en opgave eller et projekt. Det eneste sted, den landede hel, var push-beskeden på telefonen. Nu står den som et kort øverst på I dag, dér hvor man kigger, når man starter dagen. Beskeden er påmindelsen; kortet er stedet, den bliver. Teksten er gemt for dagen frem for regnet forfra, hver gang nogen kigger. En model pr. sidevisning er dyr for noget, der ikke har ændret sig — og vigtigere: en plan, der siger noget andet, når man kigger på den, end den sagde, da den kom, er værre end begge dele hver for sig. Kortet siger også, hvornår den blev lavet: en plan fra klokken syv er en anden slags oplysning klokken fire end klokken otte. En plan fra i går sendes ikke med. Et kort, der viser gårsdagens plan under overskriften "i dag", er værre end et tomt kort. "Forny planen" laver en ny, når dagen har flyttet sig, og sender ikke en besked om noget, man står og kigger på — den slags lærer folk at ignorere klokken. Og for den, der har morgenbeskeden slået fra, tilbyder kortet at lave planen på stedet, så funktionen ikke kun findes for dem, der vil vækkes. Klokken fører nu til I dag, og planen fylder de tre linjer, den er, i stedet for at blive klippet af. Co-Authored-By: Claude Opus 5 --- docs/ai.md | 20 ++- docs/notifications.md | 2 +- docs/openapi.yaml | 32 +++- internal/httpapi/ai_plan_handlers.go | 96 +++++++++++- internal/httpapi/ai_plan_test.go | 61 ++++++++ web/e2e/smoke.spec.js | 35 +++++ web/src/lib/api.js | 4 +- web/src/lib/components/Notifications.svelte | 14 +- web/src/lib/locales/da.js | 4 + web/src/lib/locales/en.js | 4 + web/src/routes/+page.svelte | 165 ++++++++++++++++++++ 11 files changed, 419 insertions(+), 18 deletions(-) diff --git a/docs/ai.md b/docs/ai.md index 4b6584b..b9efa62 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -77,11 +77,21 @@ never edited — the task carries `[[the note's title]]` in its description inst **Today's plan, in the morning.** **Settings → Beskeder → Dagens plan**, off by default; pick the hour yourself. One notification about what is due today and what -has been missed, and what to start with. The counts are done in the database and -handed to the model as facts it may not recompute — a plan that says four tasks -where there are three is worse than no plan. With no model configured the counts -stand on their own, which is what you needed to know at seven in the morning anyway. -Nothing due and nothing overdue sends nothing. +has been missed, and what to start with — and the same text as a card at the top of +**I dag**, where you are looking anyway when you start the day. The notification is +the reminder; the card is where it stays. + +The card shows the plan *as it was sent*, with the time it was made, rather than a +new one each time you look: a plan that says something different when you look at it +than it said when it arrived is worse than either. **Forny planen** makes a new one +when the day has moved, and it does not send a notification about something you are +already looking at. With the morning notification off, the card offers to make one +on the spot. + +The counts are done in the database and handed to the model as facts it may not +recompute — a plan that says four tasks where there are three is worse than no plan. +With no model configured the counts stand on their own, which is what you needed to +know at seven in the morning anyway. Nothing due and nothing overdue sends nothing. **Mail that becomes a task you can act on.** Where a [mailbox](mailboxes.md) used to produce *"Anders Jensen: SV: SV: Vedr. levering uge diff --git a/docs/notifications.md b/docs/notifications.md index 450e0e1..c03b148 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -14,7 +14,7 @@ the app's icon. | Somebody named you in a note with `@` | Instead of "edited a note" — one act, one message | | Somebody gave you a task | Only on the change, and only to the person who got it | | Somebody commented | | -| [Today's plan](ai.md), if you turned it on | In the morning, at the hour you chose | +| [Today's plan](ai.md), if you turned it on | In the morning, at the hour you chose — it opens the card on **I dag** | | A new version is out | Administrators only, once per version | Not your own actions: the server does not tell anybody what they just did diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 52669b5..b3efd12 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -3688,7 +3688,21 @@ paths: /api/v1/ai/plan/now: post: tags: [integrations] - summary: Send today's plan now, to whoever asks. + summary: Make today's plan now, to whoever asks. + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + silent: + type: boolean + description: > + Make and store it without sending a notification — the card on + Today asking for a plan while somebody is looking at it. A + notification about something in front of you is the kind that + teaches people to ignore the bell. description: | The counts come from the database and the sentence from the model, in that order: a model that counted for itself would sometimes count wrong, and a @@ -4545,8 +4559,9 @@ components: DailyPlanSettings: type: object description: > - One person's morning plan. Off by default — a notification at seven in the - morning that nobody asked for is not a help. + One person's morning plan, and today's plan itself. One resource, because + it is one thing: the plan is the resource and the setting is when it gets + made. properties: enabled: { type: boolean } hour: @@ -4555,6 +4570,17 @@ components: last_sent: type: string description: The date it last went out, in that person's own timezone. + plan: + type: string + description: > + Today's plan as it was sent — kept rather than recomputed on every + look. A plan that says something different when you look at it than it + said when it arrived is worse than either. Absent when the stored plan + is from another day. + plan_date: { type: string } + plan_at: + type: integer + description: Unix seconds. Shown on the card, because a plan made at seven reads differently at four. AISuggestion: type: object diff --git a/internal/httpapi/ai_plan_handlers.go b/internal/httpapi/ai_plan_handlers.go index 4a64b86..33a0091 100644 --- a/internal/httpapi/ai_plan_handlers.go +++ b/internal/httpapi/ai_plan_handlers.go @@ -39,6 +39,19 @@ type planSettings struct { // tidszone. En dato og ikke et tidsstempel, fordi spørgsmålet er "har de fået // dagens plan i dag" — og det er et spørgsmål om dagen dér, hvor de er. LastSent string `json:"last_sent,omitempty"` + // Plan er teksten, som den blev sendt, og PlanDate den dag den gælder. + // + // Gemt frem for regnet forfra, hver gang nogen kigger. To grunde, og den anden + // er den vigtige: en model pr. sidevisning er dyr, og en plan, der siger noget + // andet end den besked, man fik i morges, er værre end begge dele hver for + // sig. Det er den samme tekst — ellers er det ikke *dagens plan*, men et nyt + // gæt hver gang man kigger. + // + // PlanAt er klokkeslættet, den blev lavet, så kortet kan sige det: en plan fra + // klokken syv er en anden slags oplysning klokken fire end klokken otte. + Plan string `json:"plan,omitempty"` + PlanDate string `json:"plan_date,omitempty"` + PlanAt int64 `json:"plan_at,omitempty"` } func (s *Server) planSettings(ctx context.Context, userID string) (planSettings, error) { @@ -56,21 +69,44 @@ func (s *Server) planSettings(ctx context.Context, userID string) (planSettings, if v, ok := stored["last_sent"].(string); ok { out.LastSent = v } + if v, ok := stored["plan"].(string); ok { + out.Plan = v + } + if v, ok := stored["plan_date"].(string); ok { + out.PlanDate = v + } + if v, ok := stored["plan_at"].(float64); ok { + out.PlanAt = int64(v) + } return out, nil } func (s *Server) savePlanSettings(ctx context.Context, userID string, p planSettings) error { return s.db.SetUserSettings(ctx, userID, planScope, map[string]any{ "enabled": p.Enabled, "hour": p.Hour, "last_sent": p.LastSent, + "plan": p.Plan, "plan_date": p.PlanDate, "plan_at": p.PlanAt, }) } +// handleGetPlanSettings er både indstillingen og dagens plan. +// +// Ét endepunkt, fordi det er én ting: planen *er* ressourcen, og indstillingen er, +// hvornår den bliver lavet. To ville betyde to kald fra siden, der viser den, og +// to steder at huske at rydde en plan fra i går. +// +// En plan fra i går sendes ikke med. Den er ikke forkert, den er bare ikke dagens +// — og et kort, der viser gårsdagens plan under overskriften "i dag", er værre end +// et tomt kort. func (s *Server) handleGetPlanSettings(w http.ResponseWriter, r *http.Request) { - p, err := s.planSettings(r.Context(), userFrom(r.Context()).ID) + user := userFrom(r.Context()) + p, err := s.planSettings(r.Context(), user.ID) if err != nil { s.internal(w, r, "plan settings", err) return } + if p.PlanDate != time.Now().In(userLocation(user.Timezone)).Format("2006-01-02") { + p.Plan, p.PlanDate, p.PlanAt = "", "", 0 + } writeJSON(w, http.StatusOK, p) } @@ -111,12 +147,29 @@ func (s *Server) handleSetPlanSettings(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, p) } -// handlePlanNow sender dagens plan med det samme, til den der beder om det. +type planNowRequest struct { + // Silent er kortet på I dag, der beder om en plan, mens man kigger på den. + // + // En besked om noget, man står og ser på, er en besked, der lærer folk at + // ignorere klokken. Indstillingssidens knap sender derimod rigtigt — det er + // hele dens formål: at vise, at push virker på det her apparat. + Silent bool `json:"silent"` +} + +// handlePlanNow laver dagens plan med det samme, til den der beder om det. // // Findes, fordi en indstilling, man skal vente til i morgen for at se virkningen -// af, er en indstilling, ingen tør slå til. +// af, er en indstilling, ingen tør slå til — og fordi kortet på I dag skal kunne +// lave en plan for den, der har beskeden slået fra. func (s *Server) handlePlanNow(w http.ResponseWriter, r *http.Request) { + var req planNowRequest + if r.ContentLength > 0 { + if err := decodeJSON(w, r, &req); err != nil { + return + } + } user := userFrom(r.Context()) + plan, err := s.buildPlan(r.Context(), user) if err != nil { s.internal(w, r, "build plan", err) @@ -126,11 +179,36 @@ func (s *Server) handlePlanNow(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"plan": "", "sent": false}) return } - s.notify(r, &store.Notification{ - UserID: user.ID, Kind: "daily.plan", - Title: planTitle(user.Locale), Body: plan, + if err := s.rememberPlan(r.Context(), user, plan); err != nil { + s.internal(w, r, "save plan", err) + return + } + + sent := false + if !req.Silent { + s.notify(r, &store.Notification{ + UserID: user.ID, Kind: "daily.plan", + Title: planTitle(user.Locale), Body: plan, + }) + sent = true + } + writeJSON(w, http.StatusOK, map[string]any{ + "plan": plan, "sent": sent, + "plan_at": time.Now().Unix(), }) - writeJSON(w, http.StatusOK, map[string]any{"plan": plan, "sent": true}) +} + +// rememberPlan gemmer dagens plan, så den kan ses igen uden at blive lavet igen. +func (s *Server) rememberPlan(ctx context.Context, user *store.User, plan string) error { + p, err := s.planSettings(ctx, user.ID) + if err != nil { + return err + } + now := time.Now() + p.Plan = plan + p.PlanDate = now.In(userLocation(user.Timezone)).Format("2006-01-02") + p.PlanAt = now.Unix() + return s.savePlanSettings(ctx, user.ID, p) } // DailyPlans er timeslaget: hvem er der morgen hos, og hvem har ikke fået planen @@ -182,6 +260,10 @@ func (s *Server) DailyPlans(ctx context.Context) error { UserID: user.ID, Kind: "daily.plan", Title: planTitle(user.Locale), Body: plan, }) + // Gemt, og gemt her frem for i rememberPlan: rækken er allerede + // læst ind i `p`, og to gemninger oven i hinanden ville lade den + // ene overskrive den andens `last_sent`. + p.Plan, p.PlanDate, p.PlanAt = plan, today, time.Now().Unix() } p.LastSent = today if err := s.savePlanSettings(ctx, user.ID, p); err != nil { diff --git a/internal/httpapi/ai_plan_test.go b/internal/httpapi/ai_plan_test.go index d047fe1..7c3bce3 100644 --- a/internal/httpapi/ai_plan_test.go +++ b/internal/httpapi/ai_plan_test.go @@ -3,6 +3,7 @@ package httpapi import ( "net/http" "testing" + "time" ) // Planen er tal fra basen, ikke fra modellen. En plan, der siger fire opgaver, @@ -94,3 +95,63 @@ func TestAnImpossiblePlanHourIsRefused(t *testing.T) { t.Fatalf("time 25 gav %d, want 422", resp.StatusCode) } } + +// Planen gemmes for dagen, så kortet på I dag kan vise nøjagtig den tekst, +// beskeden bar — og ikke et nyt gæt, hver gang nogen kigger. +func TestTodaysPlanIsKeptForTheDay(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + ts.quickTask(t, "noget i dag") + + // Ingen plan endnu: kortet viser en knap frem for gårsdagens tekst. + if _, before := ts.do(t, "GET", "/api/v1/ai/plan", nil); before["plan"] != nil && before["plan"] != "" { + t.Fatalf("der lå en plan, før nogen havde lavet en: %v", before["plan"]) + } + + resp, made := ts.do(t, "POST", "/api/v1/ai/plan/now", map[string]any{"silent": true}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("lav planen: %d", resp.StatusCode) + } + if made["sent"] != false { + t.Errorf("en stille plan sendte en besked: %v", made["sent"]) + } + if list, _ := notifications(t, ts); len(list) != 0 { + t.Errorf("kortets egen plan gav %d beskeder, want 0", len(list)) + } + + _, after := ts.do(t, "GET", "/api/v1/ai/plan", nil) + if after["plan"] != made["plan"] { + t.Errorf("den gemte plan = %v, den lavede = %v", after["plan"], made["plan"]) + } + if after["plan_at"] == nil { + t.Error("planen bærer ikke, hvornår den blev lavet") + } +} + +// En plan fra i går er ikke dagens. Et kort, der viser gårsdagens plan under +// overskriften "i dag", er værre end et tomt kort. +func TestYesterdaysPlanIsNotTodays(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + ts.quickTask(t, "noget i dag") + + if resp, _ := ts.do(t, "POST", "/api/v1/ai/plan/now", map[string]any{"silent": true}); resp.StatusCode != http.StatusOK { + t.Fatal("lav planen") + } + + // Sat en dag tilbage, som om den var lavet i går. + user := userID(t, ts, "kristian@example.dk") + stored, err := ts.db.UserSettings(t.Context(), user, "dailyplan") + if err != nil { + t.Fatal(err) + } + stored["plan_date"] = time.Now().AddDate(0, 0, -1).Format("2006-01-02") + if err := ts.db.SetUserSettings(t.Context(), user, "dailyplan", stored); err != nil { + t.Fatal(err) + } + + _, out := ts.do(t, "GET", "/api/v1/ai/plan", nil) + if plan, _ := out["plan"].(string); plan != "" { + t.Errorf("gårsdagens plan blev vist som dagens: %q", plan) + } +} diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 0beff49..413accd 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -4325,3 +4325,38 @@ test('links kan åbnes i en anden browser, og åbner alligevel når skemaet ikke expect(trouble).toEqual([]); }); + +test('dagens plan står på I dag, og ikke kun som en besked', async ({ page }) => { + const trouble = watchForTrouble(page); + + await page.goto('/'); + const box = page.getByLabel('Ny opgave'); + await box.fill('ring til planlæggeren i dag'); + await box.press('Enter'); + await page.waitForTimeout(400); + + // Uden en model er planen to tal — og det er stadig det, man havde brug for at + // vide om morgenen. Knappen er der, fordi morgenbeskeden er slået fra som + // udgangspunkt: den, der ikke vil vækkes, skal stadig kunne få planen. + await page.getByRole('button', { name: 'Lav dagens plan' }).click(); + + const card = page.locator('.plan'); + await expect(card).toBeVisible(); + await expect(card).toContainText('forfalder i dag'); + // Klokkeslættet står på kortet: en plan fra klokken syv er en anden slags + // oplysning klokken fire end klokken otte. + await expect(card.locator('.made')).toBeVisible(); + + // Og den bliver stående — det er hele forskellen på et kort og en besked. + await page.reload(); + await expect(page.locator('.plan')).toContainText('forfalder i dag'); + + // Skjult for i dag, ikke slettet: kortet er en påmindelse, og den der har læst + // den skal kunne få den af vejen. + await page.locator('.plan .close').click(); + await expect(page.locator('.plan')).toBeHidden(); + await page.reload(); + await expect(page.locator('.plan')).toBeHidden(); + + expect(trouble).toEqual([]); +}); diff --git a/web/src/lib/api.js b/web/src/lib/api.js index 31d2ba1..8681893 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -496,7 +496,9 @@ export const api = { // Dagens plan om morgenen. planSettings: () => get('/ai/plan'), setPlanSettings: (body) => put('/ai/plan', body), - planNow: () => post('/ai/plan/now'), + // `silent` er kortet på I dag, der beder om en plan, mens man kigger på den: + // en besked om noget, man står og ser på, lærer folk at ignorere klokken. + planNow: (options) => post('/ai/plan/now', options ?? {}), version: () => get('/version'), diff --git a/web/src/lib/components/Notifications.svelte b/web/src/lib/components/Notifications.svelte index 91e5eef..c092c3a 100644 --- a/web/src/lib/components/Notifications.svelte +++ b/web/src/lib/components/Notifications.svelte @@ -56,6 +56,9 @@ // En ny version hører ingen steder hen i data — den fører hen, hvor knappen // til at gøre noget ved den sidder. if (n.kind === 'update.available') return goto('/indstillinger/notifikationer'); + // Dagens plan står på I dag, hvor den bliver ved med at stå — beskeden er + // påmindelsen, kortet er stedet. + if (n.kind === 'daily.plan') return goto('/'); if (n.note_id) goto(`/noter?note=${n.note_id}`); else if (n.task_id) app.openDetail(n.task_id); else if (n.project_id) goto(`/projekt/${n.project_id}`); @@ -111,7 +114,10 @@
  • @@ -260,6 +266,12 @@ white-space: nowrap; } + .about.full { + white-space: pre-line; + text-overflow: clip; + line-height: 1.5; + } + .ago { font-size: var(--text-xs); color: var(--ink-faint); diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index e24cb7a..36b872d 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -96,6 +96,10 @@ export const da = { 'ai.planHint': 'En besked om morgenen om det, der forfalder i dag, og det, der er sprunget over. Tallene tælles her; er der en model sat op, skriver den sætningen.', 'ai.planHour': 'Klokken', + 'ai.planMade': 'lavet {time}', + 'ai.planAgain': 'Forny planen', + 'ai.planMake': 'Lav dagens plan', + 'ai.planDismiss': 'Skjul planen i dag', 'ai.planNow': 'Send den nu', 'ai.planSent': 'Sendt — kig i klokken.', 'ai.planNothing': 'Der er ikke noget at planlægge i dag.', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index dcd3ef0..00437e7 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -93,6 +93,10 @@ export const en = { 'ai.planHint': 'A notification in the morning about what is due today and what has been missed. The numbers are counted here; if a model is set up, it writes the sentence.', 'ai.planHour': 'At', + 'ai.planMade': 'made at {time}', + 'ai.planAgain': 'Make it again', + 'ai.planMake': "Make today's plan", + 'ai.planDismiss': 'Hide the plan for today', 'ai.planNow': 'Send it now', 'ai.planSent': 'Sent — look in the bell.', 'ai.planNothing': 'There is nothing to plan for today.', diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 428d875..dad5151 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -5,6 +5,7 @@ import TaskRow from '$lib/components/TaskRow.svelte'; import QuickAdd from '$lib/components/QuickAdd.svelte'; import { t, tag } from '$lib/i18n.svelte.js'; + import { humanMessage } from '$lib/api.js'; let loaded = $state(false); @@ -17,6 +18,72 @@ ).padStart(2, '0')}`; })(); + + /** + * Dagens plan, dér hvor man kigger, når man starter dagen. + * + * Hentet og ikke regnet forfra: det er den samme tekst, beskeden i morges bar. + * En plan, der siger noget andet, når man kigger på den, end den sagde, da den + * kom, er værre end begge dele hver for sig — og en model pr. sidevisning er + * dyr for noget, der ikke har ændret sig. + * + * Lukket for i dag frem for slettet. Kortet er en påmindelse, ikke en opgave, + * og den, der har læst den, skal kunne få den af vejen uden at miste den — + * derfor står lukningen i localStorage med dagens dato og ikke på kontoen. + */ + let plan = $state(null); + let planBusy = $state(false); + let planError = $state(''); + const DISMISS_KEY = 'verdande:plan-dismissed'; + + let dismissed = $state( + typeof localStorage !== 'undefined' && localStorage.getItem(DISMISS_KEY) === isoToday + ); + + function dismissPlan() { + dismissed = true; + try { + localStorage.setItem(DISMISS_KEY, isoToday); + } catch { + // Privat vindue; kortet er væk til næste indlæsning. + } + } + + async function makePlan() { + if (planBusy) return; + planBusy = true; + planError = ''; + try { + // Stille: man står og kigger på den. En besked om noget, man har foran + // sig, er den slags, der lærer folk at ignorere klokken. + const made = await api.planNow({ silent: true }); + plan = { ...plan, plan: made.plan, plan_at: made.plan_at }; + dismissed = false; + } catch (e) { + planError = humanMessage(e); + } finally { + planBusy = false; + } + } + + $effect(() => { + api + .planSettings() + .then((p) => (plan = p)) + .catch(() => { + // Et kort, der ikke kunne hentes, må ikke stå i vejen for listen. + }); + }); + + const planMade = $derived( + plan?.plan_at + ? new Date(plan.plan_at * 1000).toLocaleTimeString(tag(), { + hour: '2-digit', + minute: '2-digit' + }) + : '' + ); + async function load() { const data = await api.today(); app.tasks = [...data.overdue, ...data.today]; @@ -54,6 +121,33 @@ + {#if plan?.plan && !dismissed} + +
    +
    +

    {t('ai.plan')}

    + {#if planMade}{t('ai.planMade', { time: planMade })}{/if} + +
    +

    {plan.plan}

    + +
    + {:else if plan && !plan.plan && (liveToday.length || liveOverdue.length)} + +

    + + {#if planError}{planError}{/if} +

    + {/if} + {#if liveOverdue.length}

    {t('view.overdue')}

    @@ -93,6 +187,77 @@ margin-bottom: var(--s5); } + /* Kortet ligger mellem feltet og listen: under det, man skriver i, og over det, + man skal se på. Dæmpet frem for fremhævet — det er en påmindelse om, hvad + dagen indeholder, ikke en opgave, der skal laves. */ + .plan { + margin: 0 0 var(--s5); + padding: var(--s3); + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface-raised, var(--surface)); + } + + .plan header { + display: flex; + align-items: baseline; + gap: var(--s2); + margin: 0 0 var(--s2); + } + + .plan h2 { + font-size: var(--text-sm); + margin: 0; + } + + .plan .made { + color: var(--ink-faint); + font-size: var(--text-xs); + } + + .plan .close { + margin-left: auto; + border: none; + background: none; + color: var(--ink-faint); + cursor: pointer; + line-height: 1; + font-size: var(--text-md); + } + + /* Linjeskift bevaret: planen er to-tre linjer, og de er skrevet som linjer. */ + .plan p { + margin: 0 0 var(--s2); + font-size: var(--text-sm); + line-height: 1.6; + white-space: pre-line; + } + + .again { + border: none; + background: none; + padding: 0; + font: inherit; + font-size: var(--text-xs); + color: var(--accent); + cursor: pointer; + } + + .again:disabled { + color: var(--ink-faint); + cursor: default; + } + + .planask { + margin: 0 0 var(--s5); + } + + .planerror { + margin-left: var(--s2); + font-size: var(--text-xs); + color: var(--ink-muted); + } + h1 { font-size: var(--text-2xl); }