Compare commits

..
9 Commits
Author SHA1 Message Date
Frederic CabassutandClaude Opus 4.6 0894c10c95 style: redesign UI with parchment theme and JetBrains Mono font
Replace the default dark/white theme with a warm parchment aesthetic
using CSS custom properties, retro pixel overlay, and JetBrains Mono
monospace font across all components. Bump version to 0.1.7.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:51:10 +01:00
Frederic CabassutandClaude Opus 4.6 a9779c93aa fix: pass app version via server load instead of Vite define
The Svelte 5 SSR compiler was optimizing away the __APP_VERSION__
constant set via Vite's define, causing the version to be missing
from the production footer. Use a +layout.server.ts load function
to pass the version from package.json instead.

Also bumps version to 0.1.6.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 07:18:59 +01:00
Frederic CabassutandClaude Opus 4.6 3b3a1b4ff0 chore: bump version to 0.1.5
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 06:55:03 +01:00
Frederic CabassutandClaude Opus 4.6 c73061a74e fix: run Dokploy redeploy after Docker image push
Move the Dokploy webhook into a separate deploy job that depends on
the release job, ensuring the image is fully pushed before redeploying.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 06:36:56 +01:00
Frederic CabassutandClaude Opus 4.6 e56016a5b2 fix: resolve __APP_VERSION__ type error in svelte-check
Move the global type declaration inside declare global block and
reference the constant via a local variable in the script block.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 06:14:59 +01:00
Frederic CabassutandClaude Opus 4.6 ca8362d151 feat: display app version in footer
Inject version from package.json at build time via Vite's define and
show it in the layout footer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 00:05:25 +01:00
Frederic CabassutandClaude Opus 4.6 4fcb21d191 feat: enable PWA offline support with service worker
Add SvelteKitPWA plugin to generate a service worker that precaches
static assets and uses NetworkFirst for API routes, enabling the app
to load offline. Generate pretzel emoji PWA icons and remove the old
static manifest (now generated by the plugin).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 00:03:34 +01:00
Frederic CabassutandClaude Opus 4.6 cc92652358 feat: trigger Dokploy redeploy after release
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:55:45 +01:00
Frederic CabassutandClaude Opus 4.6 c27b391a72 fix: add .dockerignore to prevent host node_modules in image
Excludes node_modules, build artifacts, and test data from the Docker
build context. Fixes invalid ELF header error when building on macOS
for Linux containers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:29:10 +01:00
28 changed files with 213 additions and 133 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
build
.svelte-kit
data
*.db
playwright-report
test-results
+8
View File
@@ -54,3 +54,11 @@ jobs:
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
deploy:
name: Deploy
runs-on: ubuntu-latest
needs: release
steps:
- name: Trigger Dokploy redeploy
run: curl -X POST "${{ secrets.DOKPLOY_WEBHOOK_URL }}"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "crumbs",
"private": true,
"version": "0.1.2",
"version": "0.1.7",
"type": "module",
"scripts": {
"dev": "vite dev",
+52
View File
@@ -1,2 +1,54 @@
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap');
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
:root {
--bg-base: #f0e6d3;
--bg-surface: #faf5eb;
--primary: #C8860A;
--primary-hover: #E8A020;
--text: #1a1a2e;
--text-muted: #6b6272;
--border: #1a1a2e;
--border-subtle: #d4cabb;
}
body {
font-family: 'JetBrains Mono', monospace;
}
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0.03;
background-image: repeating-conic-gradient(#1a1a2e 0% 25%, transparent 0% 50%);
background-size: 4px 4px;
z-index: 0;
}
/* Prose overrides for parchment theme */
.prose {
--tw-prose-body: var(--text);
--tw-prose-headings: var(--text);
--tw-prose-links: var(--primary);
--tw-prose-code: var(--text);
--tw-prose-pre-bg: var(--bg-surface);
--tw-prose-pre-code: var(--text);
}
.prose :where(code):not(:where([class~="not-prose"] *)) {
background-color: var(--bg-surface);
border-radius: 2px;
padding: 0.125rem 0.25rem;
}
.prose :where(a):not(:where([class~="not-prose"] *)) {
color: var(--primary);
}
.prose :where(a):not(:where([class~="not-prose"] *)):hover {
color: var(--primary-hover);
}
+3 -1
View File
@@ -3,7 +3,9 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#ffffff" />
<meta name="theme-color" content="#f0e6d3" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="icon"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🥨</text></svg>"
+4 -4
View File
@@ -83,7 +83,7 @@
type="checkbox"
checked={item.checked}
onchange={() => toggleItem(index)}
class="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500"
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
data-testid="checklist-checkbox"
/>
<input
@@ -91,7 +91,7 @@
value={item.text}
oninput={(e) => updateText(index, (e.target as HTMLInputElement).value)}
onkeydown={(e) => handleKeydown(e, index)}
class="flex-1 bg-transparent text-sm outline-none {item.checked ? 'text-gray-400 line-through' : 'text-gray-800'}"
class="flex-1 bg-transparent text-sm outline-none {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
placeholder="List item"
data-testid="checklist-input"
/>
@@ -101,7 +101,7 @@
aria-label="Remove item"
data-testid="checklist-remove"
>
<svg class="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="h-4 w-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@@ -109,7 +109,7 @@
{/each}
<button
onclick={() => addItem(items.length - 1)}
class="flex items-center gap-2 text-sm text-gray-500 hover:text-gray-700"
class="flex items-center gap-2 text-sm text-[var(--text-muted)] hover:text-[var(--text)]"
data-testid="checklist-add"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+1 -1
View File
@@ -15,7 +15,7 @@
{@const colors = NOTE_COLORS[option.value]}
<button
onclick={() => onSelect(option.value)}
class="h-8 w-8 rounded-full border-2 transition-transform hover:scale-110 {selected === option.value ? 'border-gray-800' : 'border-transparent'}"
class="h-8 w-8 rounded-full border-2 transition-transform hover:scale-110 {selected === option.value ? 'border-[var(--text)]' : 'border-transparent'}"
style="background-color: {colors.bg}"
title={option.label}
data-testid="color-{option.value}"
+20 -20
View File
@@ -80,15 +80,15 @@
const chevronSize = 14;
function btnClass(active: boolean = false, disabled: boolean = false): string {
return `rounded p-1.5 text-gray-600 hover:bg-black/10 ${active ? 'bg-black/10' : ''} ${disabled ? 'opacity-30' : ''}`;
return `rounded p-1.5 text-[var(--text)] hover:bg-[var(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''} ${disabled ? 'opacity-30' : ''}`;
}
function dropdownBtnClass(active: boolean = false): string {
return `flex items-center gap-0.5 rounded p-1.5 text-gray-600 hover:bg-black/10 ${active ? 'bg-black/10' : ''}`;
return `flex items-center gap-0.5 rounded p-1.5 text-[var(--text)] hover:bg-[var(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''}`;
}
function dropdownItemClass(active: boolean = false): string {
return `flex w-full items-center gap-2 rounded px-3 py-1.5 text-sm text-gray-700 hover:bg-black/5 ${active ? 'bg-black/5' : ''}`;
return `flex w-full items-center gap-2 rounded px-3 py-1.5 text-sm text-[var(--text)] hover:bg-[var(--border)]/5 ${active ? 'bg-[var(--border)]/5' : ''}`;
}
function handlePointerDown(event: PointerEvent) {
@@ -109,7 +109,7 @@
<svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} />
<div
class="flex flex-wrap items-center gap-0.5 border-b border-gray-200/50 px-2 py-1"
class="flex flex-wrap items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1"
data-testid="formatting-toolbar"
>
<!-- History -->
@@ -132,7 +132,7 @@
<Redo2 size={iconSize} />
</button>
<div class="mx-1 h-4 w-px bg-gray-300"></div>
<div class="mx-1 h-4 w-px bg-[var(--border-subtle)]"></div>
<!-- Heading dropdown -->
<div class="relative" data-dropdown="heading">
@@ -146,13 +146,13 @@
<ChevronDown size={chevronSize} />
</button>
{#if openDropdown === 'heading'}
<div class="absolute left-0 top-full z-50 mt-1 min-w-[150px] rounded-lg border border-gray-200 bg-white py-1 shadow-lg">
<div class="absolute left-0 top-full z-50 mt-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<button
onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('heading', { level: 1 }) ?? false)}
data-testid="format-h1"
>
<span class="w-6 text-xs font-semibold text-gray-400">H1</span>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H1</span>
<span class="font-semibold">Heading 1</span>
</button>
<button
@@ -160,7 +160,7 @@
class={dropdownItemClass(editor?.isActive('heading', { level: 2 }) ?? false)}
data-testid="format-h2"
>
<span class="w-6 text-xs font-semibold text-gray-400">H2</span>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H2</span>
<span>Heading 2</span>
</button>
<button
@@ -168,7 +168,7 @@
class={dropdownItemClass(editor?.isActive('heading', { level: 3 }) ?? false)}
data-testid="format-h3"
>
<span class="w-6 text-xs font-semibold text-gray-400">H3</span>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H3</span>
<span>Heading 3</span>
</button>
</div>
@@ -187,7 +187,7 @@
<ChevronDown size={chevronSize} />
</button>
{#if openDropdown === 'list'}
<div class="absolute left-0 top-full z-50 mt-1 min-w-[170px] rounded-lg border border-gray-200 bg-white py-1 shadow-lg">
<div class="absolute left-0 top-full z-50 mt-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<button
onclick={() => { editor?.chain().focus().toggleBulletList().run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('bulletList') ?? false)}
@@ -225,7 +225,7 @@
<CodeXml size={iconSize} />
</button>
<div class="mx-1 h-4 w-px bg-gray-300"></div>
<div class="mx-1 h-4 w-px bg-[var(--border-subtle)]"></div>
<!-- Inline -->
<button
@@ -280,7 +280,7 @@
<Link size={iconSize} />
</button>
{#if openDropdown === 'link'}
<div class="absolute left-1/2 top-full z-50 mt-1 -translate-x-1/2 rounded-lg border border-gray-200 bg-white px-2 py-1.5 shadow-lg">
<div class="absolute left-1/2 top-full z-50 mt-1 -translate-x-1/2 rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1.5">
<form
class="flex items-center gap-1"
onsubmit={(e) => { e.preventDefault(); applyLink(); }}
@@ -290,23 +290,23 @@
bind:value={linkUrl}
type="url"
placeholder="Paste a link..."
class="w-44 bg-transparent px-1.5 py-1 text-sm text-gray-700 outline-none placeholder:text-gray-400"
class="w-44 bg-transparent px-1.5 py-1 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
data-testid="format-link-input"
/>
<button
type="submit"
class="rounded p-1 text-gray-400 hover:bg-black/5 hover:text-gray-600"
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--text)]"
title="Apply link"
data-testid="format-link-apply"
>
<CornerDownLeft size={16} />
</button>
<div class="mx-0.5 h-4 w-px bg-gray-300"></div>
<div class="mx-0.5 h-4 w-px bg-[var(--border-subtle)]"></div>
<button
type="button"
onclick={openLink}
disabled={!editor?.getAttributes('link').href}
class="rounded p-1 text-gray-400 hover:bg-black/5 hover:text-gray-600 disabled:opacity-30"
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--text)] disabled:opacity-30"
title="Open link"
data-testid="format-link-open"
>
@@ -316,7 +316,7 @@
type="button"
onclick={removeLink}
disabled={!editor?.isActive('link')}
class="rounded p-1 text-gray-400 hover:bg-black/5 hover:text-red-500 disabled:opacity-30"
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-red-500 disabled:opacity-30"
title="Remove link"
data-testid="format-unlink"
>
@@ -327,7 +327,7 @@
{/if}
</div>
<div class="mx-1 h-4 w-px bg-gray-300"></div>
<div class="mx-1 h-4 w-px bg-[var(--border-subtle)]"></div>
<!-- Align dropdown -->
<div class="relative" data-dropdown="align">
@@ -341,7 +341,7 @@
<ChevronDown size={chevronSize} />
</button>
{#if openDropdown === 'align'}
<div class="absolute left-0 top-full z-50 mt-1 min-w-[150px] rounded-lg border border-gray-200 bg-white py-1 shadow-lg">
<div class="absolute left-0 top-full z-50 mt-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<button
onclick={() => { editor?.chain().focus().setTextAlign('left').run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive({ textAlign: 'left' }) ?? false)}
@@ -378,7 +378,7 @@
{/if}
</div>
<div class="mx-1 h-4 w-px bg-gray-300"></div>
<div class="mx-1 h-4 w-px bg-[var(--border-subtle)]"></div>
<!-- Insert -->
<button
+4 -4
View File
@@ -60,7 +60,7 @@
<img
src="/api/notes/{noteId}/attachments/{attachment.id}"
alt={attachment.filename}
class="h-20 w-20 rounded object-cover"
class="h-20 w-20 rounded-sm object-cover"
data-testid="attachment-thumbnail"
/>
<button
@@ -80,7 +80,7 @@
<!-- Upload area -->
<div
class="rounded-lg border-2 border-dashed p-4 text-center transition-colors {dragOver ? 'border-amber-500 bg-amber-50' : 'border-gray-300'}"
class="rounded-sm border-2 border-dashed p-4 text-center transition-colors {dragOver ? 'border-[var(--primary)] bg-[var(--primary)]/5' : 'border-[var(--border-subtle)]'}"
ondrop={handleDrop}
ondragover={handleDragOver}
ondragleave={() => (dragOver = false)}
@@ -89,10 +89,10 @@
data-testid="upload-dropzone"
>
{#if uploading}
<p class="text-sm text-gray-500">Uploading...</p>
<p class="text-sm text-[var(--text-muted)]">Uploading...</p>
{:else}
<label class="cursor-pointer">
<span class="text-sm text-gray-500">Drop images here or <span class="text-amber-600 underline">browse</span></span>
<span class="text-sm text-[var(--text-muted)]">Drop images here or <span class="text-[var(--primary)] underline">browse</span></span>
<input
type="file"
accept="image/*"
+4 -4
View File
@@ -9,20 +9,20 @@
let { onMenuToggle }: Props = $props();
</script>
<header class="sticky top-0 z-30 flex h-16 items-center gap-4 border-b border-gray-200 bg-white px-4 shadow-sm">
<header class="sticky top-0 z-30 flex h-16 items-center gap-4 border-b border-[var(--border)] bg-[var(--bg-surface)] px-4">
<button
onclick={onMenuToggle}
class="rounded-full p-2 hover:bg-gray-100"
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
aria-label="Toggle sidebar"
>
<svg class="h-6 w-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="h-6 w-6 text-[var(--text)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div class="flex items-center gap-2">
<span class="text-2xl">🥨</span>
<h1 class="text-xl font-semibold text-gray-800">Crumbs</h1>
<h1 class="font-['Press_Start_2P'] text-lg text-[var(--primary)]">Crumbs</h1>
</div>
<div class="mx-4 flex-1">
+7 -7
View File
@@ -21,14 +21,14 @@
</script>
<aside
class="fixed left-0 top-16 z-20 h-[calc(100vh-4rem)] w-64 transform border-r border-gray-200 bg-white transition-transform duration-200 {open ? 'translate-x-0' : '-translate-x-full'} lg:translate-x-0"
class="fixed left-0 top-16 z-20 h-[calc(100vh-4rem)] w-64 transform border-r border-[var(--border)] bg-[var(--bg-surface)] transition-transform duration-200 {open ? 'translate-x-0' : '-translate-x-full'} lg:translate-x-0"
>
<nav class="p-2">
<ul class="space-y-1">
<li>
<button
onclick={() => setFilter('all')}
class="flex w-full items-center gap-3 rounded-full px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'all' && !$selectedTag ? 'bg-amber-100 text-amber-900' : 'text-gray-700 hover:bg-gray-100'}"
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'all' && !$selectedTag ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
>
<StickyNote size={20} />
Crumbs
@@ -37,7 +37,7 @@
<li>
<button
onclick={() => setFilter('archived')}
class="flex w-full items-center gap-3 rounded-full px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'archived' ? 'bg-amber-100 text-amber-900' : 'text-gray-700 hover:bg-gray-100'}"
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'archived' ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
>
<Archive size={20} />
Archive
@@ -46,7 +46,7 @@
<li>
<button
onclick={() => setFilter('trashed')}
class="flex w-full items-center gap-3 rounded-full px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'trashed' ? 'bg-amber-100 text-amber-900' : 'text-gray-700 hover:bg-gray-100'}"
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'trashed' ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
>
<Trash2 size={20} />
Trash
@@ -55,14 +55,14 @@
</ul>
{#if $allTags.length > 0}
<div class="mt-6 border-t border-gray-200 pt-4">
<h3 class="px-6 text-xs font-semibold uppercase tracking-wider text-gray-500">Tags</h3>
<div class="mt-6 border-t border-[var(--border-subtle)] pt-4">
<h3 class="px-6 text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">Tags</h3>
<ul class="mt-2 space-y-1">
{#each $allTags as tag}
<li>
<button
onclick={() => selectTag($selectedTag === tag ? null : tag)}
class="flex w-full items-center gap-3 rounded-full px-6 py-2 text-left text-sm transition-colors {$selectedTag === tag ? 'bg-amber-100 text-amber-900' : 'text-gray-700 hover:bg-gray-100'}"
class="flex w-full items-center gap-3 rounded-sm px-6 py-2 text-left text-sm transition-colors {$selectedTag === tag ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
>
<Tag size={16} />
#{tag}
+1 -1
View File
@@ -32,7 +32,7 @@
<div class="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-2">
{#each $toasts as toast (toast.id)}
<div
class="flex items-center gap-3 rounded-lg px-4 py-3 shadow-lg {toast.type === 'error' ? 'bg-red-600 text-white' : toast.type === 'success' ? 'bg-green-600 text-white' : 'bg-gray-800 text-white'}"
class="flex items-center gap-3 rounded-sm border border-[var(--border)] px-4 py-3 {toast.type === 'error' ? 'bg-red-600 text-white' : toast.type === 'success' ? 'bg-green-600 text-white' : 'bg-[var(--text)] text-[var(--bg-base)]'}"
>
<span class="text-sm">{toast.text}</span>
{#if toast.action}
+15 -15
View File
@@ -43,7 +43,7 @@
</script>
<article
class="group relative cursor-pointer rounded-lg border border-gray-200 p-4 shadow-sm transition-shadow hover:shadow-md"
class="group relative cursor-pointer rounded-sm border border-[var(--border)] p-4 transition-colors hover:border-[var(--primary)]"
style={cardStyle}
onclick={() => onEdit(note)}
onkeydown={(e) => e.key === 'Enter' && onEdit(note)}
@@ -53,23 +53,23 @@
data-note-id={note.id}
>
{#if note.title}
<h3 class="mb-2 text-sm font-semibold text-gray-900">{note.title}</h3>
<h3 class="mb-2 text-sm font-semibold text-[var(--text)]">{note.title}</h3>
{/if}
{#if note.checklistMode && checklistItems.length > 0}
<ul class="space-y-1" data-testid="note-checklist-preview">
{#each checklistItems.slice(0, 8) as item}
<li class="flex items-center gap-2 text-sm {item.checked ? 'text-gray-400 line-through' : 'text-gray-700'}">
<input type="checkbox" checked={item.checked} disabled class="h-3.5 w-3.5 rounded border-gray-300 text-amber-600" />
<li class="flex items-center gap-2 text-sm {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}">
<input type="checkbox" checked={item.checked} disabled class="h-3.5 w-3.5 rounded border-[var(--border-subtle)] text-[var(--primary)]" />
<span class="truncate">{item.text}</span>
</li>
{/each}
{#if checklistItems.length > 8}
<li class="text-xs text-gray-400">+{checklistItems.length - 8} more</li>
<li class="text-xs text-[var(--text-muted)]">+{checklistItems.length - 8} more</li>
{/if}
</ul>
{:else if note.content}
<div class="prose prose-sm line-clamp-6 max-w-none text-sm text-gray-700" data-testid="note-content-preview">
<div class="prose prose-sm line-clamp-6 max-w-none text-sm text-[var(--text-muted)]" data-testid="note-content-preview">
{@html renderedContent}
</div>
{/if}
@@ -77,7 +77,7 @@
{#if note.tags && note.tags.length > 0}
<div class="mt-2 flex flex-wrap gap-1">
{#each note.tags as tag}
<span class="rounded-full bg-gray-200/60 px-2 py-0.5 text-xs text-gray-600">
<span class="rounded-sm bg-[var(--border)]/10 px-2 py-0.5 text-xs text-[var(--text-muted)]">
#{tag}
</span>
{/each}
@@ -89,7 +89,7 @@
{#if $currentFilter === 'trashed'}
<button
onclick={stop(() => restoreNote(note.id))}
class="rounded-full p-1.5 hover:bg-black/10"
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Restore"
data-testid="restore-btn"
>
@@ -99,7 +99,7 @@
</button>
<button
onclick={stop(() => deleteNote(note.id))}
class="rounded-full p-1.5 hover:bg-black/10"
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Delete forever"
data-testid="delete-forever-btn"
>
@@ -110,18 +110,18 @@
{:else}
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="rounded-full p-1.5 hover:bg-black/10"
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title={note.pinned ? 'Unpin' : 'Pin'}
data-testid="pin-btn"
>
<svg class="h-4 w-4 {note.pinned ? 'text-amber-600' : ''}" fill={note.pinned ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
<svg class="h-4 w-4 {note.pinned ? 'text-[var(--primary)]' : ''}" fill={note.pinned ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
</button>
{#if $currentFilter === 'archived'}
<button
onclick={stop(() => unarchiveNote(note.id))}
class="rounded-full p-1.5 hover:bg-black/10"
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Unarchive"
data-testid="unarchive-btn"
>
@@ -132,7 +132,7 @@
{:else}
<button
onclick={stop(() => archiveNote(note.id))}
class="rounded-full p-1.5 hover:bg-black/10"
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Archive"
data-testid="archive-btn"
>
@@ -143,7 +143,7 @@
{/if}
<button
onclick={stop(() => trashNote(note.id))}
class="rounded-full p-1.5 hover:bg-black/10"
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Delete"
data-testid="trash-btn"
>
@@ -157,7 +157,7 @@
<!-- Pin indicator -->
{#if note.pinned}
<div class="absolute right-2 top-2">
<svg class="h-4 w-4 text-amber-600" fill="currentColor" viewBox="0 0 24 24">
<svg class="h-4 w-4 text-[var(--primary)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
</div>
+10 -10
View File
@@ -78,7 +78,7 @@
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mx-4 w-full max-w-xl rounded-lg border border-gray-200 shadow-xl"
class="mx-4 w-full max-w-xl rounded-sm border border-[var(--border)]"
style={bgStyle}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { e.stopPropagation(); handleKeydown(e); }}
@@ -89,7 +89,7 @@
type="text"
placeholder="Title"
bind:value={title}
class="w-full bg-transparent px-4 pt-4 text-lg font-semibold text-gray-900 outline-none placeholder:text-gray-500"
class="w-full bg-transparent px-4 pt-4 text-lg font-semibold text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
data-testid="note-title-input"
/>
@@ -103,7 +103,7 @@
bind:this={textareaEl}
placeholder="Add a crumb..."
bind:value={content}
class="min-h-[300px] w-full resize-none bg-transparent px-4 py-2 text-sm text-gray-800 outline-none placeholder:text-gray-500"
class="min-h-[300px] w-full resize-none bg-transparent px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
rows="12"
data-testid="note-content-input"
></textarea>
@@ -123,22 +123,22 @@
{/if}
<!-- Toolbar -->
<div class="flex items-center justify-between border-t border-gray-200/50 px-2 py-2">
<div class="flex items-center justify-between border-t border-[var(--border-subtle)] px-2 py-2">
<div class="flex items-center gap-1">
<!-- Color picker toggle -->
<div class="relative">
<button
onclick={() => (showColorPicker = !showColorPicker)}
class="rounded-full p-2 hover:bg-black/10"
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Background color"
data-testid="color-picker-toggle"
>
<svg class="h-5 w-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="h-5 w-5 text-[var(--text-muted)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
</button>
{#if showColorPicker}
<div class="absolute left-0 top-full mt-2 rounded-lg border border-gray-200 bg-white p-2 shadow-lg">
<div class="absolute left-0 top-full mt-2 rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-2">
<ColorPicker selected={color} onSelect={handleColorSelect} />
</div>
{/if}
@@ -147,7 +147,7 @@
<!-- Checklist mode toggle -->
<button
onclick={() => (checklistMode = !checklistMode)}
class="rounded-full p-2 hover:bg-black/10 {checklistMode ? 'text-amber-600' : ''}"
class="rounded-sm p-2 hover:bg-[var(--border)]/10 {checklistMode ? 'text-[var(--primary)]' : ''}"
title="Checklist mode"
data-testid="checklist-toggle"
>
@@ -159,7 +159,7 @@
<!-- Raw markdown mode toggle -->
<button
onclick={toggleMarkdownMode}
class="rounded-full p-2 hover:bg-black/10 {rawMarkdownMode ? 'text-amber-600' : ''}"
class="rounded-sm p-2 hover:bg-[var(--border)]/10 {rawMarkdownMode ? 'text-[var(--primary)]' : ''}"
title="Markdown mode"
data-testid="markdown-toggle"
>
@@ -171,7 +171,7 @@
<button
onclick={save}
class="rounded px-4 py-1 text-sm font-medium text-gray-700 hover:bg-black/10"
class="rounded-sm px-4 py-1 text-sm font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
data-testid="close-editor-btn"
>
Close
+4 -4
View File
@@ -37,8 +37,8 @@
</script>
<div class="relative max-w-2xl">
<div class="flex items-center rounded-lg bg-gray-100 px-4 py-2">
<svg class="mr-3 h-5 w-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="flex items-center rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 focus-within:border-[var(--primary)]">
<svg class="mr-3 h-5 w-5 text-[var(--text-muted)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
@@ -46,11 +46,11 @@
placeholder="Search crumbs..."
bind:value={query}
oninput={handleSearch}
class="w-full bg-transparent text-sm text-gray-800 outline-none placeholder:text-gray-500"
class="w-full bg-transparent text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
data-testid="search-input"
/>
{#if query}
<button onclick={clearSearch} class="ml-2 text-gray-500 hover:text-gray-700" aria-label="Clear search">
<button onclick={clearSearch} class="ml-2 text-[var(--text-muted)] hover:text-[var(--text)]" aria-label="Clear search">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
+1 -1
View File
@@ -11,7 +11,7 @@
{#each $allTags as tag}
<button
onclick={() => toggleTag(tag)}
class="rounded-full px-3 py-1 text-xs font-medium transition-colors {$selectedTag === tag ? 'bg-amber-200 text-amber-900' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}"
class="rounded-sm px-3 py-1 text-xs font-medium transition-colors {$selectedTag === tag ? 'bg-[var(--primary)] text-white' : 'border border-[var(--border-subtle)] bg-[var(--bg-surface)] text-[var(--text-muted)] hover:border-[var(--primary)]'}"
data-testid="tag-chip"
>
#{tag}
+2 -2
View File
@@ -59,7 +59,7 @@
<div
bind:this={element}
class="tiptap-wrapper prose prose-sm min-h-[300px] max-w-none px-4 py-2 text-gray-800"
class="tiptap-wrapper prose prose-sm min-h-[300px] max-w-none px-4 py-2 text-[var(--text)]"
data-testid="tiptap-editor"
></div>
@@ -72,7 +72,7 @@
.tiptap-wrapper :global(.tiptap p.is-editor-empty:first-child::before) {
content: attr(data-placeholder);
float: left;
color: #9ca3af;
color: var(--text-muted);
pointer-events: none;
height: 0;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import type { NoteColor } from '$lib/types/index.js';
export const NOTE_COLORS: Record<NoteColor, { bg: string; label: string }> = {
default: { bg: '#ffffff', label: 'Default' },
default: { bg: '#faf5eb', label: 'Default' },
coral: { bg: '#faafa8', label: 'Coral' },
peach: { bg: '#f39f76', label: 'Peach' },
sand: { bg: '#fff8b8', label: 'Sand' },
+5
View File
@@ -0,0 +1,5 @@
import { version } from '../../package.json';
export function load() {
return { appVersion: version };
}
+4 -4
View File
@@ -6,7 +6,7 @@
import { loadNotes } from '$lib/stores/notes.js';
import { onMount } from 'svelte';
let { children } = $props();
let { data, children } = $props();
let sidebarOpen = $state(false);
onMount(() => {
@@ -14,7 +14,7 @@
});
</script>
<div class="flex min-h-screen flex-col bg-white text-gray-900">
<div class="flex min-h-screen flex-col bg-[var(--bg-base)] text-[var(--text)]">
<Header onMenuToggle={() => (sidebarOpen = !sidebarOpen)} />
<Sidebar open={sidebarOpen} />
@@ -24,8 +24,8 @@
</div>
</main>
<footer class="pb-4 pt-8 text-center text-xs text-gray-400 lg:ml-64">
Crumbs by Bretzel &mdash; made with 🥨 in Strasbourg
<footer class="pb-4 pt-8 text-center text-xs text-[var(--text-muted)] lg:ml-64">
Crumbs by Bretzel v{data.appVersion} &mdash; made with 🥨 in Strasbourg
</footer>
<Toast />
+7 -9
View File
@@ -26,7 +26,7 @@
<div class="mx-auto mb-6 max-w-xl">
<button
onclick={() => (showNewNote = true)}
class="w-full rounded-lg border border-gray-300 px-4 py-3 text-left text-sm text-gray-500 shadow-sm transition-shadow hover:shadow-md"
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-4 py-3 text-left text-sm text-[var(--text-muted)] transition-colors hover:border-[var(--primary)]"
data-testid="new-note-btn"
>
Add a crumb...
@@ -39,9 +39,9 @@
</div>
{#if $currentFilter === 'archived'}
<h2 class="mb-4 text-lg font-medium text-gray-600">Archive</h2>
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Archive</h2>
{:else if $currentFilter === 'trashed'}
<h2 class="mb-4 text-lg font-medium text-gray-600">Trash</h2>
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Trash</h2>
{/if}
{#if $pinnedNotes.length > 0}
@@ -57,11 +57,9 @@
/>
{#if $pinnedNotes.length === 0 && $unpinnedNotes.length === 0}
<div class="flex flex-col items-center justify-center py-20 text-gray-400">
<svg class="mb-4 h-24 w-24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
<p class="text-lg">
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-muted)]">
<span class="mb-4 text-7xl">🥨</span>
<p class="font-['Press_Start_2P'] text-xs">
{#if $currentFilter === 'trashed'}
No crumbs in trash
{:else if $currentFilter === 'archived'}
@@ -69,7 +67,7 @@
{:else if $selectedTag}
No crumbs with tag #{$selectedTag}
{:else}
Your crumbs will appear here
No crumbs yet
{/if}
</p>
</div>
+7 -7
View File
@@ -35,17 +35,17 @@
<title>Login - Crumbs</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center bg-gray-50">
<div class="w-full max-w-sm rounded-lg bg-white p-8 shadow-md">
<div class="flex min-h-screen items-center justify-center bg-[var(--bg-base)]">
<div class="w-full max-w-sm rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-8">
<div class="mb-6 text-center">
<span class="text-5xl">🥨</span>
<h1 class="text-2xl font-bold text-gray-800">Crumbs</h1>
<p class="mt-1 text-sm text-gray-500">Enter your password to continue</p>
<h1 class="font-['Press_Start_2P'] text-xl text-[var(--primary)]">Crumbs</h1>
<p class="mt-2 text-sm text-[var(--text-muted)]">Enter your password to continue</p>
</div>
<form onsubmit={handleLogin}>
{#if errorMsg}
<div class="mb-4 rounded bg-red-50 p-3 text-sm text-red-600" data-testid="error-message">
<div class="mb-4 rounded-sm border border-red-300 bg-red-50 p-3 text-sm text-red-600" data-testid="error-message">
{errorMsg}
</div>
{/if}
@@ -54,7 +54,7 @@
type="password"
bind:value={password}
placeholder="Password"
class="mb-4 w-full rounded-lg border border-gray-300 px-4 py-3 text-sm outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-500"
class="mb-4 w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
data-testid="password-input"
required
/>
@@ -62,7 +62,7 @@
<button
type="submit"
disabled={loading}
class="w-full rounded-lg bg-amber-500 py-3 text-sm font-medium text-white transition-colors hover:bg-amber-600 disabled:opacity-50"
class="w-full rounded-sm bg-[var(--primary)] py-3 text-sm font-medium text-white transition-colors hover:bg-[var(--primary-hover)] disabled:opacity-50"
data-testid="login-btn"
>
{loading ? 'Signing in...' : 'Sign in'}
+8 -8
View File
@@ -47,17 +47,17 @@
<title>Setup - Crumbs</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center bg-gray-50">
<div class="w-full max-w-sm rounded-lg bg-white p-8 shadow-md">
<div class="flex min-h-screen items-center justify-center bg-[var(--bg-base)]">
<div class="w-full max-w-sm rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-8">
<div class="mb-6 text-center">
<span class="text-5xl">🥨</span>
<h1 class="text-2xl font-bold text-gray-800">Welcome to Crumbs</h1>
<p class="mt-1 text-sm text-gray-500">Set up your password to get started</p>
<h1 class="font-['Press_Start_2P'] text-xl text-[var(--primary)]">Welcome to Crumbs</h1>
<p class="mt-2 text-sm text-[var(--text-muted)]">Set up your password to get started</p>
</div>
<form onsubmit={handleSetup}>
{#if errorMsg}
<div class="mb-4 rounded bg-red-50 p-3 text-sm text-red-600" data-testid="error-message">
<div class="mb-4 rounded-sm border border-red-300 bg-red-50 p-3 text-sm text-red-600" data-testid="error-message">
{errorMsg}
</div>
{/if}
@@ -66,7 +66,7 @@
type="password"
bind:value={password}
placeholder="Password (min 8 characters)"
class="mb-3 w-full rounded-lg border border-gray-300 px-4 py-3 text-sm outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-500"
class="mb-3 w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
data-testid="password-input"
required
/>
@@ -75,7 +75,7 @@
type="password"
bind:value={confirmPassword}
placeholder="Confirm password"
class="mb-4 w-full rounded-lg border border-gray-300 px-4 py-3 text-sm outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-500"
class="mb-4 w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
data-testid="confirm-password-input"
required
/>
@@ -83,7 +83,7 @@
<button
type="submit"
disabled={loading}
class="w-full rounded-lg bg-amber-500 py-3 text-sm font-medium text-white transition-colors hover:bg-amber-600 disabled:opacity-50"
class="w-full rounded-sm bg-[var(--primary)] py-3 text-sm font-medium text-white transition-colors hover:bg-[var(--primary-hover)] disabled:opacity-50"
data-testid="setup-btn"
>
{loading ? 'Setting up...' : 'Set password & start'}
-27
View File
@@ -1,27 +0,0 @@
{
"name": "Crumbs by Bretzel",
"short_name": "Crumbs",
"description": "A self-hostable, offline-first note-taking app by Bretzel",
"start_url": "/",
"display": "standalone",
"background_color": "#202124",
"theme_color": "#f59e0b",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

+1 -1
View File
@@ -4,7 +4,7 @@ test.describe('Notes CRUD', () => {
test('Scenario: Fresh account shows an empty notes list', async ({ authenticatedPage: page }) => {
// Given the user has no notes
// Then the empty state message is displayed
await expect(page.getByText('Your crumbs will appear here')).toBeVisible();
await expect(page.getByText('No crumbs yet')).toBeVisible();
});
test('Scenario: New note appears in the notes list after creation', async ({ authenticatedPage: page }) => {
+36 -1
View File
@@ -1,7 +1,42 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()]
plugins: [
tailwindcss(),
sveltekit(),
SvelteKitPWA({
registerType: 'autoUpdate',
manifest: {
name: 'Crumbs by Bretzel',
short_name: 'Crumbs',
description: 'A self-hostable, offline-first note-taking app by Bretzel',
start_url: '/',
display: 'standalone',
background_color: '#f0e6d3',
theme_color: '#C8860A',
icons: [
{ src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' },
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
]
},
workbox: {
globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}'],
navigateFallback: '/',
runtimeCaching: [
{
urlPattern: /^https?:\/\/.*\/api\//,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: { maxEntries: 50, maxAgeSeconds: 300 }
}
}
]
}
})
]
});