Something went wrong. Try again.
a local-first atmospheric chalky task planner
Something went wrong. Try again.
9.9 kB · 304 lines
Svelte
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305<script lang="ts"> import { settings } from "../store/settings.svelte"; import { theme } from "../store/theme.svelte"; import { PRESETS, inkFor, normalizeHex } from "../palette";
// `id` names an existing palette entry to edit; null means "new color" — the // entry isn't created until Save, so backing out never leaves a half-made // (hidden-forever) ghost in the palette. `onDone` returns to the Colors page // whichever way the modal ends. let { id = null, onDone }: { id?: string | null; onDone: () => void } = $props();
// Draft-based editing: nothing touches the palette until Save. Seeded once on // mount — from the entry being edited, or (for a new color) from the first // preset no current entry already uses, name included. Capturing the initial // `id` is deliberate: the settings modal remounts this page per entry, so it // never changes while mounted. // svelte-ignore state_referenced_locally const existing = id ? settings.palette.find((c) => c.id === id) : undefined; const seed = (() => { if (existing) return { hex: existing.hex, name: existing.name ?? "" }; const used = new Set(settings.palette.map((c) => c.hex.toLowerCase())); const preset = PRESETS.find((p) => !used.has(p.hex.toLowerCase())) ?? PRESETS[0]; return { hex: preset.hex, name: preset.name }; })(); let draftHex = $state(seed.hex); let draftName = $state(seed.name);
let canSave = $derived(draftName.trim().length > 0);
function save() { if (!canSave) return; if (id) settings.updateColor(id, { hex: draftHex, name: draftName }); else settings.addColor({ hex: draftHex, name: draftName }); onDone(); }
// Picking a preset adopts its name too (still editable in the Name field); // the custom inputs only move the hex, keeping whatever name is typed. function pickPreset(p: { hex: string; name: string }) { draftHex = p.hex; draftName = p.name; }
// Editable hex text mirrors the draft color, but only while the field // isn't focused — otherwise a resync on every keystroke would stomp a // partial, not-yet-valid edit (e.g. typing "#f" while it walks toward // "#ffa3a3"). let hexFocused = $state(false); let hexDraft = $state(seed.hex); $effect(() => { if (!hexFocused) hexDraft = draftHex; });
// Takes any CSS color (`#fa0`, `rgb(...)`, `oklch(...)`, `rebeccapurple`); // `normalizeHex` reduces it to the stored 6-digit hex, and an unparseable // entry is ignored, as before. function commitHexDraft(raw: string) { const v = normalizeHex(raw); if (v) draftHex = v; }
// The two theme blocks in app.css (`:root[data-mode="dark"]` / // `:root[data-mode="light"]`) as of this writing: // dark: --bg #2b322d --text #ecefe3 // light: --bg #f3f0e7 --text #2c302a // Hardcoded here (rather than read from CSS vars) because this page can show // both themes' previews at once when the mode is "system", and a CSS var // only ever holds the one currently-applied theme's value. Chip ink isn't // part of this table — it's a function of the tint alone (see `inkFor`), // the same for both theme panels, so it's computed once below instead. const THEME_PREVIEW = { dark: { bg: "#2b322d", text: "#ecefe3" }, light: { bg: "#f3f0e7", text: "#2c302a" }, } as const;
let previewModes = $derived( theme.mode === "system" ? (["light", "dark"] as const) : ([theme.mode] as const), );
// Same helper (and the same ink candidates) `App.svelte` uses to set // `--hl-ink-<id>` on real chips, so this preview always matches what the // app will actually render for this hex. let chipInk = $derived(inkFor(draftHex));</script>
<div class="picker-page"> <div class="sect"> <div class="sect-title">Preview</div> <div class="previews" class:single={previewModes.length === 1}> {#each previewModes as m (m)} {@const t = THEME_PREVIEW[m]} <div class="preview" style:background={t.bg}> {#if previewModes.length > 1}<span class="preview-label" style:color={t.text}>{m}</span>{/if} <span class="fake-chip" style:background={draftHex} style:color={chipInk}>Sample Task</span> </div> {/each} </div> </div>
<div class="sect"> <div class="sect-title">Color</div> <div class="presets" role="group" aria-label="Preset colors"> {#each PRESETS as p (p.name)} {@const selected = draftHex.toLowerCase() === p.hex.toLowerCase()} <button class="preset" class:selected style:background={p.hex} data-tip={p.name} aria-label={p.name} aria-pressed={selected} onclick={() => pickPreset(p)} ></button> {/each} </div> <div class="custom"> <input class="swatch-input" type="color" value={draftHex} aria-label="Custom color" oninput={(e) => (draftHex = (e.currentTarget as HTMLInputElement).value)} /> <input class="hex-input" type="text" value={hexDraft} spellcheck="false" aria-label="Color value" onfocus={() => (hexFocused = true)} oninput={(e) => (hexDraft = (e.currentTarget as HTMLInputElement).value)} onblur={(e) => { hexFocused = false; commitHexDraft((e.currentTarget as HTMLInputElement).value); }} onchange={(e) => commitHexDraft((e.currentTarget as HTMLInputElement).value)} onkeydown={(e) => { if (e.key === "Enter") commitHexDraft((e.currentTarget as HTMLInputElement).value); }} /> </div> </div>
<div class="sep"></div>
<div class="sect"> <label class="sect-title" for="color-name">Name</label> <div class="footer"> <input id="color-name" class="name-input" type="text" required placeholder="Name (required)" bind:value={draftName} spellcheck="false" onkeydown={(e) => { if (e.key === "Enter") save(); }} /> <button class="cancel-btn" onclick={onDone}>Cancel</button> <button class="save-btn" disabled={!canSave} title={canSave ? undefined : "Name the color to save it"} onclick={save} > Save </button> </div> </div></div>
<style> .picker-page { display: flex; flex-direction: column; gap: 16px; } .sect { display: flex; flex-direction: column; gap: 10px; } .sect-title { font-size: var(--fs-small); font-weight: 700; color: var(--text); text-transform: uppercase; letter-spacing: 0.05em; } .sep { height: 1px; background: var(--line); }
.previews { display: flex; gap: 10px; } .preview { flex: 1 1 0; display: flex; flex-direction: column; align-items: flex-start; gap: 6px; padding: 14px; border-radius: var(--radius-sm); border: 1px solid var(--line); } .previews.single .preview { flex: none; width: 100%; } .preview-label { font-size: var(--fs-small); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.6; } /* Mirrors TaskItem's .chip: same radius/padding/font, tint via inline style. */ .fake-chip { display: inline-block; padding: 1px 6px; border-radius: var(--radius-sm); font-family: var(--font); font-size: var(--fs-body); line-height: 1.35; }
/* Name + Cancel/Save as one form-footer row; the input takes the slack. */ .footer { display: flex; align-items: center; gap: 8px; } .name-input { flex: 1 1 auto; font: inherit; color: var(--text); background-color: var(--surface); border: 1px solid var(--line-strong); border-radius: var(--radius-sm); padding: 5px 8px; outline: none; min-width: 0; } .name-input:focus-visible { border-color: var(--text); } .name-input::placeholder { color: var(--text-dim); }
.presets { display: flex; flex-wrap: wrap; gap: 8px; } .preset { width: 26px; height: 26px; border-radius: 50%; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12); } .preset.selected { outline: 2px solid var(--text); outline-offset: 2px; }
.custom { display: flex; align-items: center; gap: 8px; } .swatch-input { flex: none; width: 30px; height: 30px; padding: 0; border: 1px solid var(--line-strong); border-radius: 50%; overflow: hidden; background: none; cursor: var(--cur-pointer); } /* Native color-input chrome varies wildly by browser — clip it to a round swatch so it matches the rest of the palette's circular chips. */ .swatch-input::-webkit-color-swatch-wrapper { padding: 0; } .swatch-input::-webkit-color-swatch { border: none; border-radius: 50%; } .swatch-input::-moz-color-swatch { border: none; border-radius: 50%; }
.hex-input { flex: 1 1 auto; font: inherit; color: var(--text); background-color: var(--surface); border: 1px solid var(--line-strong); border-radius: var(--radius-sm); padding: 5px 8px; outline: none; min-width: 0; text-transform: lowercase; } .hex-input:focus-visible { border-color: var(--text); }
.cancel-btn { flex: none; padding: 6px 14px; border-radius: var(--radius-sm); border: 1px solid var(--line-strong); background: var(--surface); color: var(--text); font-size: var(--fs-body); font-weight: 600; } .save-btn { flex: none; padding: 6px 14px; border-radius: var(--radius-sm); background: var(--text); color: var(--bg); font-size: var(--fs-body); font-weight: 600; } .save-btn:disabled { opacity: 0.5; cursor: default; } @media (hover: hover) { .cancel-btn:hover { background: var(--surface-alt); } .save-btn:hover:not(:disabled) { filter: brightness(0.92); } }</style>