Browse Source

Update, explode and break styles and components

master
Nuša Pukšič 7 months ago committed by buttercat1791
parent
commit
b6d0dfb73a
  1. 3355
      deno.lock
  2. 7445
      package-lock.json
  3. 13
      package.json
  4. 9
      postcss.config.js
  5. 52
      scripts/a-ui.manifest.json
  6. 84
      scripts/scaffold.ts
  7. 200
      src/app.css
  8. 19
      src/lib/a/index.ts
  9. 11
      src/lib/a/nav/AFooter.svelte
  10. 0
      src/lib/a/nav/ANavDropdown.svelte
  11. 82
      src/lib/a/nav/ANavbar.svelte
  12. 41
      src/lib/a/nav/AUserDropdown.svelte
  13. 15
      src/lib/a/nav/nav-types.ts
  14. 39
      src/lib/a/primitives/AButton.svelte
  15. 6
      src/lib/a/primitives/ACard.svelte
  16. 16
      src/lib/a/primitives/ADetails.svelte
  17. 6
      src/lib/a/primitives/AInput.svelte
  18. 27
      src/lib/a/primitives/ANostrBadge.svelte
  19. 19
      src/lib/a/primitives/ANostrBadgeRow.svelte
  20. 113
      src/lib/a/primitives/ANostrUser.svelte
  21. 10
      src/lib/a/primitives/ASwitch.svelte
  22. 10
      src/lib/a/primitives/AThemeToggleMini.svelte
  23. 7
      src/lib/a/reader/AReaderPage.svelte
  24. 70
      src/lib/a/reader/AReaderTOC.svelte
  25. 48
      src/lib/a/reader/AReaderToolbar.svelte
  26. 18
      src/lib/a/reader/ATechBlock.svelte
  27. 11
      src/lib/a/reader/ATechToggle.svelte
  28. 32
      src/lib/a/reader/ATocNode.svelte
  29. 1
      src/lib/a/reader/scroll-spy.ts
  30. 4
      src/lib/a/reader/toc-utils.ts
  31. 4
      src/lib/components/EventRenderLevelLimit.svelte
  32. 2
      src/lib/components/publications/Publication.svelte
  33. 2
      src/lib/components/util/CardActions.svelte
  34. 40
      src/lib/nav/site-nav.ts
  35. 2
      src/lib/navigator/EventNetwork/Legend.svelte
  36. 1
      src/lib/nostr/event.ts
  37. 1
      src/lib/nostr/format.ts
  38. 1
      src/lib/nostr/nip05.ts
  39. 1
      src/lib/nostr/nip58.ts
  40. 1
      src/lib/nostr/types.ts
  41. 5
      src/lib/stores/techStore.ts
  42. 3
      src/lib/styles/cva.ts
  43. 32
      src/lib/theme/build-tokens.ts
  44. 93
      src/lib/theme/generated/themes.css
  45. 6
      src/lib/theme/theme-store.ts
  46. 15
      src/lib/theme/themes/dark.yaml
  47. 15
      src/lib/theme/themes/light.yaml
  48. 15
      src/lib/theme/themes/sepia.yaml
  49. 16
      src/routes/+layout.svelte
  50. 8
      src/routes/+page.svelte
  51. 2
      src/routes/contact/+page.svelte
  52. 2
      src/styles/base.css
  53. 40
      src/theme-tokens.css
  54. 123
      tailwind.config.cjs
  55. 2
      vite.config.ts

3355
deno.lock

File diff suppressed because it is too large Load Diff

7445
package-lock.json generated

File diff suppressed because it is too large Load Diff

13
package.json

@ -13,7 +13,8 @@ @@ -13,7 +13,8 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write .",
"test": "vitest"
"test": "vitest",
"tokens": "node src/lib/theme/build-tokens.mjs"
},
"dependencies": {
"@noble/curves": "^1.9.4",
@ -22,9 +23,11 @@ @@ -22,9 +23,11 @@
"@nostr-dev-kit/ndk-cache-dexie": "2.6.x",
"@popperjs/core": "2.11.x",
"@tailwindcss/forms": "0.5.x",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/typography": "0.5.x",
"asciidoctor": "3.0.x",
"bech32": "^2.0.0",
"class-variance-authority": "^0.7.1",
"d3": "^7.9.0",
"he": "1.2.x",
"highlight.js": "^11.11.1",
@ -47,8 +50,8 @@ @@ -47,8 +50,8 @@
"@types/qrcode": "^1.5.5",
"autoprefixer": "^10.4.21",
"eslint-plugin-svelte": "^3.11.0",
"flowbite": "2.x",
"flowbite-svelte": "0.48.x",
"flowbite": "~2.5.2",
"flowbite-svelte": "1.11.x",
"flowbite-svelte-icons": "2.1.x",
"playwright": "^1.50.1",
"postcss": "^8.5.6",
@ -58,10 +61,10 @@ @@ -58,10 +61,10 @@
"svelte": "^5.36.8",
"svelte-check": "4.x",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^3.4.17",
"tslib": "2.8.x",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vitest": "^3.1.3"
"vitest": "^3.1.3",
"yaml": "^2.5.0"
}
}

9
postcss.config.js

@ -1,6 +1,5 @@ @@ -1,6 +1,5 @@
import tailwindcss from "tailwindcss";
import autoprefixer from "autoprefixer";
export default {
plugins: [tailwindcss(), autoprefixer()],
};
plugins: {
'@tailwindcss/postcss': {}
}
}

52
scripts/a-ui.manifest.json

@ -0,0 +1,52 @@ @@ -0,0 +1,52 @@
{
"files": [
{
"path": "src/lib/theme/build-tokens.ts",
"content": "#!/usr/bin/env -S deno run --allow-read --allow-write\nimport { join } from \"https://deno.land/std@0.224.0/path/mod.ts\";\nimport { parse as parseYaml } from \"https://deno.land/std@0.224.0/yaml/mod.ts\";\nconst themesDir = \"src/lib/theme/themes\";\nconst outCss = \"src/lib/theme/generated/themes.css\";\nfunction toRgb(hex: string) {\n const h = hex.replace(\"#\", \"\").trim();\n const n = (s: string) => parseInt(s, 16);\n if (h.length === 3) return `${n(h[0]+h[0])} ${n(h[1]+h[1])} ${n(h[2]+h[2])}`;\n return `${n(h.slice(0,2))} ${n(h.slice(2,4))} ${n(h.slice(4,6))}`;\n}\nconst entries: string[] = [];\nfor await (const ent of Deno.readDir(themesDir)) if (ent.isFile && ent.name.endsWith(\".yaml\")) entries.push(ent.name);\nentries.sort();\nlet css = \"\";\nfor (const file of entries) {\n const t = parseYaml(await Deno.readTextFile(join(themesDir, file))) as any;\n const sel = t.name === \"light\" ? ':root,[data-theme=\"light\"]' : `[data-theme=\"${t.name}\"]`;\n css += `${sel}{\\n`;\n for (const [k, v] of Object.entries(t.colors ?? {})) css += `--color-${k}: ${toRgb(String(v))};\\n`;\n for (const [k, v] of Object.entries(t.radii ?? {})) css += `--radius-${k}: ${v};\\n`;\n for (const [k, v] of Object.entries(t.spacing ?? {})) css += `--space-${k}: ${v};\\n`;\n const ty = t.typography ?? {};\n if (ty[\"font-reading\"]) css += `--font-reading: ${ty[\"font-reading\"]};\\n`;\n if (ty[\"font-ui\"]) css += `--font-ui: ${ty[\"font-ui\"]};\\n`;\n if (ty[\"leading-reading\"]) css += `--leading-reading: ${ty[\"leading-reading\"]};\\n`;\n if (ty[\"measure-ch\"]) css += `--measure-ch: ${ty[\"measure-ch\"]};\\n`;\n css += `}\\n\\n`;\n}\nawait Deno.mkdir(join(\"src/lib/theme/generated\"), { recursive: true });\nawait Deno.writeTextFile(outCss, css);\nconsole.log(\"Wrote\", outCss);\n"
},
{
"path": "deno.json",
"content": "{\n \"tasks\": {\n \"tokens\": \"deno run --allow-read --allow-write src/lib/theme/build-tokens.ts\",\n \"scaffold\": \"deno run --allow-read --allow-write scripts/scaffold.ts scripts/a-ui.v5.manifest.json\"\n }\n}\n"
},
{
"path": "src/lib/a/primitives/AButton.svelte",
"content": "<script lang=\"ts\">\n import { cva, twMerge } from '$lib/styles/cva';\n let {\n variant = 'solid',\n size = 'md',\n as = 'button',\n disabled = false,\n class: className = '',\n // common attrs (no $$restProps in runes)\n href = undefined as string | undefined,\n target = undefined as string | undefined,\n rel = undefined as string | undefined,\n type = 'button',\n onclick = undefined as undefined | ((e:MouseEvent)=>void)\n } = $props();\n\n const styles = cva(\n 'inline-flex items-center justify-center font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-primary/40 transition',\n { variants: { variant:\n { solid: 'bg-primary text-[rgb(var(--color-primary-contrast,255 255 255))] hover:bg-primary/90',\n outline: 'border border-primary text-primary hover:bg-primary/10',\n ghost: 'text-primary hover:bg-primary/10' },\n size: { sm: 'h-8 px-3 text-sm', md: 'h-10 px-4', lg: 'h-12 px-5 text-lg' } },\n defaultVariants: { variant: 'solid', size: 'md' } }\n );\n</script>\n\n<svelte:element\n this={as}\n class={twMerge(styles({ variant, size }), className)}\n disabled={as === 'button' ? disabled : undefined}\n href={as === 'a' ? href : undefined}\n target={as === 'a' ? target : undefined}\n rel={as === 'a' ? rel : undefined}\n type={as === 'button' ? type : undefined}\n onclick={onclick}\n>\n <slot />\n</svelte:element>\n"
},
{
"path": "src/lib/a/primitives/AInput.svelte",
"content": "<script lang=\"ts\"> let { value = '', class: className = '', placeholder = '' } = $props(); </script>\n<input\n class={`w-full h-10 px-3 rounded-md border border-muted/30 bg-surface text-text placeholder:opacity-60 focus:outline-none focus:ring-2 focus:ring-primary/40 ${className}`}\n bind:value\n placeholder={placeholder}\n/>\n"
},
{
"path": "src/lib/a/primitives/ACard.svelte",
"content": "<script lang=\"ts\"> let { class: className = '' } = $props(); </script>\n<div class={`rounded-lg border border-muted/20 bg-surface shadow-sm ${className}`}>\n <slot name=\"header\" />\n <div class=\"p-4\"><slot /></div>\n <slot name=\"footer\" />\n</div>\n"
},
{
"path": "src/lib/a/primitives/ASwitch.svelte",
"content": "<script lang=\"ts\">\n let { checked = false, class: className = '', onchange = undefined as undefined | ((v:boolean)=>void) } = $props();\n function toggle(){ checked = !checked; onchange?.(checked); }\n</script>\n<button type=\"button\" role=\"switch\" aria-checked={checked}\n onclick={toggle}\n class={`inline-flex items-center h-6 w-11 rounded-full border border-muted/30 transition px-0.5 ${checked ? 'bg-primary' : 'bg-surface'} ${className}`}\n>\n <span class={`inline-block h-5 w-5 rounded-full bg-white shadow transform transition ${checked ? 'translate-x-5' : 'translate-x-0'}`} />\n</button>\n"
},
{
"path": "src/lib/a/primitives/ADetails.svelte",
"content": "<script lang=\"ts\">\n import { showTech } from '$lib/tech/tech-store';\n let { summary = '', tech = false, defaultOpen = false, forceHide = false, class: className = '' } = $props();\n let open = defaultOpen;\n $: if (tech && !$showTech) open = false;\n function onToggle(e: Event){ const el = e.currentTarget as HTMLDetailsElement; open = el.open; }\n</script>\n<details open={open} ontoggle={onToggle} class={`group rounded-lg border border-muted/20 bg-surface ${className}`} data-kind={tech ? 'tech':'general'}>\n <summary class=\"flex items-center gap-2 cursor-pointer list-none px-3 py-2 rounded-lg select-none hover:bg-primary/10\">\n <svg class={`h-4 w-4 transition-transform ${open ? 'rotate-90':''}`} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 18l6-6-6-6\"/></svg>\n <span class=\"font-medium\">{summary}</span>\n {#if tech}<span class=\"ml-2 text-[10px] uppercase tracking-wide rounded px-1.5 py-0.5 border border-primary/30 text-primary bg-primary/5\">Technical</span>{/if}\n <span class=\"ml-auto text-xs opacity-60 group-open:opacity-50\">{open ? 'Hide':'Show'}</span>\n </summary>\n {#if !(tech && !$showTech && forceHide)}<div class=\"px-3 pb-3 pt-1 text-[0.95rem] leading-6\"><slot /></div>{/if}\n</details>\n"
},
{
"path": "src/lib/a/reader/AReaderTOC.svelte",
"content": "<script lang=\"ts\">\n import { onMount } from 'svelte';\n import TocNode from './TocNode.svelte';\n import type { TocItem } from './toc-utils';\n let { items = [] as TocItem[], activeId = null as string | null, collapsible = true, expandDepth = 1, class: className = '', onnavigate = undefined as undefined | ((href:string)=>void) } = $props();\n let expanded = new Set<string>();\n let parentOf = new Map<string, string | null>();\n function mapParents(list: TocItem[], parent: string | null = null){ for (const it of list){ parentOf.set(it.id, parent); if (it.children?.length) mapParents(it.children, it.id); } }\n $: mapParents(items);\n function initExpansion(list: TocItem[], depth = 0){ for (const it of list){ if (depth < expandDepth) expanded.add(it.id); if (it.children?.length) initExpansion(it.children, depth + 1); } expanded = new Set(expanded); }\n function expandAncestors(id: string | null){ if (!id) return; let cur: string | null | undefined = id; while (cur){ expanded.add(cur); cur = parentOf.get(cur) ?? null; } expanded = new Set(expanded); }\n onMount(()=>{ initExpansion(items,0); expandAncestors(activeId); if (activeId){ const el = document.querySelector(`[data-toc-id=\"${activeId}\"]`); if (el instanceof HTMLElement) el.scrollIntoView({ block:'nearest' }); } });\n $: if (activeId) expandAncestors(activeId);\n function toggle(id: string){ if (!collapsible) return; if (expanded.has(id)) expanded.delete(id); else expanded.add(id); expanded = new Set(expanded); }\n const pad = (depth:number)=>`padding-left: calc(var(--space-4) * ${Math.max(depth,0)})`;\n const onNavigate = (href:string)=> onnavigate?.(href);\n</script>\n<nav aria-label=\"Table of contents\" class={`text-sm ${className}`}>\n <ul class=\"space-y-1 max-h-[calc(100vh-6rem)] overflow-auto pr-1\">\n {#each items as item (item.id)}\n <TocNode {item} depth={0} {activeId} {collapsible} {expanded} {toggle} {pad} {onNavigate} />\n {/each}\n </ul>\n</nav>\n"
},
{
"path": "src/lib/a/reader/TocNode.svelte",
"content": "<script lang=\"ts\">\n import type { TocItem } from './toc-utils';\n import TocNode from './TocNode.svelte';\n let { item, depth = 0, activeId = null as string | null, collapsible = true, expanded, toggle, pad, onNavigate }: { item: TocItem; depth?: number; activeId?: string|null; collapsible?: boolean; expanded: Set<string>; toggle: (id:string)=>void; pad: (depth:number)=>string; onNavigate: (href:string)=>void; } = $props();\n const hasChildren = !!(item.children && item.children.length > 0);\n $: isOpen = expanded.has(item.id);\n $: isActive = activeId === item.id;\n</script>\n<li>\n <div class=\"flex items-center gap-1 rounded-md hover:bg-primary/10\" style={pad(depth)}>\n {#if collapsible && hasChildren}\n <button class=\"shrink-0 h-6 w-6 grid place-items-center rounded-md hover:bg-primary/10\" aria-label={isOpen?`Collapse ${item.title}`:`Expand ${item.title}`} aria-expanded={isOpen} onclick={() => toggle(item.id)}>\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class={isOpen ? 'rotate-90 transition-transform' : 'transition-transform'}><path d=\"M9 18l6-6-6-6\" /></svg>\n </button>\n {:else}\n <span class=\"shrink-0 h-6 w-6\" />\n {/if}\n <a href={item.href ?? `#${item.id}`} data-toc-id={item.id}\n class=\"flex-1 min-w-0 rounded-md px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-primary/40\"\n class:hover=\"bg-primary/10\" class:text-primary={isActive} class:bg-primary/10={isActive}\n onclick={() => onNavigate(item.href ?? `#${item.id}`)}>\n <span class=\"truncate\">{item.title}</span>\n </a>\n </div>\n {#if hasChildren}\n <ul id={`sub-${item.id}`} aria-hidden={!isOpen} class={isOpen ? 'mt-1 space-y-1' : 'hidden'}>\n {#each item.children as child (child.id)}\n <TocNode item={child} depth={depth + 1} {activeId} {collapsible} {expanded} {toggle} {pad} {onNavigate} />\n {/each}\n </ul>\n {/if}\n</li>\n"
},
{
"path": "src/lib/a/nav/ANavbar.svelte",
"content": "<script lang=\"ts\">\n import type { NavItem, UserInfo } from './nav-types';\n import UserDropdown from './UserDropdown.svelte';\n import MobileNav from './MobileNav.svelte';\n import { onMount } from 'svelte';\n let { items = [] as NavItem[], currentPath = '', user = null as UserInfo | null, userMenu = [] as NavItem[], logo = null, brand = 'Alexandria', onselect = undefined as undefined | ((i:NavItem)=>void) } = $props();\n let mobileOpen = false; let openId: string | null = null;\n function onKey(e: KeyboardEvent){ if (e.key === 'Escape'){ openId = null; mobileOpen = false; } }\n onMount(()=>{ window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); });\n const topItems = items.map((it,i)=>({ ...it, id: it.id || `${(it.title||'item').toLowerCase().replace(/[^\\w]+/g,'-')}-${i}` }));\n function isActive(href?: string){ if (!href) return false; try { return new URL(href, 'http://x').pathname === currentPath; } catch { return href === currentPath; } }\n</script>\n<header class=\"sticky top-0 z-40 border-b border-muted/20 bg-surface/95 backdrop-blur supports-[backdrop-filter]:bg-surface/80\">\n <div class=\"mx-auto max-w-7xl px-3 sm:px-4 lg:px-6\">\n <div class=\"h-14 flex items-center gap-3\">\n <button class=\"lg:hidden inline-flex h-9 w-9 items-center justify-center rounded-md border border-muted/30\" aria-label=\"Open menu\" onclick={() => (mobileOpen = true)}>\n <svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><line x1=\"3\" y1=\"6\" x2=\"21\" y2=\"6\"/><line x1=\"3\" y1=\"12\" x2=\"21\" y2=\"12\"/><line x1=\"3\" y1=\"18\" x2=\"21\" y2=\"18\"/></svg>\n </button>\n <a href=\"/\" class=\"flex items-center gap-2\">{#if logo}<svelte:component this={logo} />{/if}<span class=\"font-semibold\">{brand}</span></a>\n <nav class=\"ml-4 hidden lg:flex items-stretch gap-1\">\n {#each topItems as item}\n <div class=\"relative\" onmouseleave={() => (openId = null)}>\n <button class=\"px-2.5 h-10 rounded-md inline-flex items-center gap-1.5 hover:bg-primary/10 focus:outline-none focus:ring-2 focus:ring-primary/40\"\n aria-haspopup={item.children?.length ? 'menu' : undefined}\n aria-expanded={openId === item.id}\n onmouseenter={() => (openId = item.children?.length ? item.id! : null)}\n onfocus={() => (openId = item.children?.length ? item.id! : null)}\n onclick={() => (openId = openId === item.id ? null : (item.children?.length ? item.id! : null))}\n >\n {#if item.icon}<svelte:component this={item.icon} class=\"h-4 w-4\" />{/if}\n <a href={item.href || '#'} class=\"px-0.5 rounded-md\" class:text-primary={isActive(item.href)}>{item.title}</a>\n {#if item.children?.length}<svg class=\"h-4 w-4 opacity-70\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"6 9 12 15 18 9\"/></svg>{/if}\n {#if item.badge}<span class=\"text-[10px] ml-1 rounded px-1.5 py-0.5 border border-primary/30 text-primary bg-primary/5\">{item.badge}</span>{/if}\n </button>\n {#if item.children?.length && openId === item.id}\n <div class=\"absolute left-0 mt-1 w-[min(90vw,48rem)] rounded-lg border border-muted/20 bg-surface shadow-lg p-3\">\n <div class=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3\">\n {#each item.children as section}\n <div>\n <a href={section.href || '#'} class=\"font-medium hover:underline flex items-center gap-2\">{#if section.icon}<svelte:component this={section.icon} class=\"h-4 w-4\" />{/if}{section.title}</a>\n {#if section.children?.length}\n <ul class=\"mt-2 space-y-1\">\n {#each section.children as leaf}\n <li><a href={leaf.href || '#'} target={leaf.external ? '_blank' : undefined} rel={leaf.external ? 'noreferrer' : undefined} class=\"block rounded-md px-2 py-1 hover:bg-primary/10\">{leaf.title}</a></li>\n {/each}\n </ul>\n {/if}\n </div>\n {/each}\n </div>\n </div>\n {/if}\n </div>\n {/each}\n </nav>\n <div class=\"ml-auto flex items-center gap-2\">\n <slot name=\"search\" />\n <UserDropdown {user} items={userMenu} onselect={onselect} />\n </div>\n </div>\n </div>\n <MobileNav bind:open={mobileOpen} {items} {user} userItems={userMenu} />\n</header>\n"
},
{
"path": "src/lib/a/nav/UserDropdown.svelte",
"content": "<script lang=\"ts\">\n import type { NavItem, UserInfo } from './nav-types';\n import { onMount } from 'svelte';\n let { user = null as UserInfo | null, items = [] as NavItem[], onselect = undefined as undefined | ((i:NavItem)=>void) } = $props();\n let open = false; let btn: HTMLButtonElement;\n function onDoc(e: MouseEvent){ if (!open) return; if (!(e.target instanceof Node)) return; if (!btn?.parentElement?.contains(e.target)) open = false; }\n onMount(()=>{ document.addEventListener('mousedown', onDoc); return () => document.removeEventListener('mousedown', onDoc); });\n</script>\n<div class=\"relative\">\n <button bind:this={btn} class=\"inline-flex items-center gap-2 h-9 px-2 rounded-md border border-muted/30 hover:bg-primary/10\" aria-haspopup=\"menu\" aria-expanded={open} onclick={() => (open = !open)}>\n <img src={user?.avatarUrl || 'https://via.placeholder.com/24'} alt=\"\" class=\"h-6 w-6 rounded-full object-cover\" />\n <span class=\"hidden sm:block text-sm\">{user?.name || 'Account'}</span>\n <svg class=\"h-4 w-4 opacity-70\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"6 9 12 15 18 9\"/></svg>\n </button>\n {#if open}\n <div class=\"absolute right-0 mt-1 min-w-[14rem] rounded-lg border border-muted/20 bg-surface shadow-lg py-1 z-50\">\n {#if user}\n <div class=\"px-3 py-2 text-sm\">\n <div class=\"font-medium\">{user.name}</div>\n {#if user.email}<div class=\"opacity-70\">{user.email}</div>{/if}\n </div>\n <div class=\"my-1 h-px bg-muted/20\" />\n {/if}\n <ul>\n {#each items as it}\n {#if it.divider}\n <li class=\"my-1 h-px bg-muted/20\"></li>\n {:else}\n <li>\n <a href={it.href || '#'} target={it.external ? '_blank':undefined} rel={it.external ? 'noreferrer':undefined}\n class=\"flex items-center gap-2 px-3 py-2 text-sm hover:bg-primary/10\"\n onclick={(e)=>{ if (!it.href || it.href==='#'){ e.preventDefault(); open=false; onselect?.(it); } else { open=false; } }}>\n {it.title}\n {#if it.badge}<span class=\"ml-auto text-[10px] rounded px-1.5 py-0.5 border border-primary/30 text-primary bg-primary/5\">{it.badge}</span>{/if}\n </a>\n </li>\n {/if}\n {/each}\n </ul>\n </div>\n {/if}\n</div>\n"
},
{
"path": "src/lib/a/nav/MobileNav.svelte",
"content": "<script lang=\"ts\">\n import type { NavItem, UserInfo } from './nav-types';\n let { open = false, items = [] as NavItem[], user = null as UserInfo | null, userItems = [] as NavItem[] } = $props();\n function close(){ open = false; }\n</script>\n{#if open}\n <div class=\"fixed inset-0 z-50\">\n <div class=\"absolute inset-0 bg-black/40\" onclick={close}></div>\n <aside class=\"absolute left-0 top-0 h-full w-[88vw] max-w-sm bg-surface border-r border-muted/20 shadow-xl p-3 overflow-y-auto\">\n <div class=\"flex items-center gap-3\">\n <img src={user?.avatarUrl || 'https://via.placeholder.com/32'} alt=\"\" class=\"h-8 w-8 rounded-full\" />\n <div>\n <div class=\"text-sm font-medium\">{user?.name || 'Welcome'}</div>\n {#if user?.email}<div class=\"text-xs opacity-70\">{user.email}</div>{/if}\n </div>\n <button class=\"ml-auto h-8 w-8 grid place-items-center rounded-md border border-muted/30\" onclick={close} aria-label=\"Close\">\n <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\"/><line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\"/></svg>\n </button>\n </div>\n <nav class=\"mt-4\">\n <ul class=\"space-y-1\">\n {#each items as item}\n <li>\n {#if item.children?.length}\n <details>\n <summary class=\"flex items-center gap-2 cursor-pointer list-none rounded-md px-2 py-2 hover:bg-primary/10\">\n <span class=\"flex-1\">{item.title}</span>\n <svg class=\"h-4 w-4\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"6 9 12 15 18 9\"/></svg>\n </summary>\n <ul class=\"mt-1 ml-3 space-y-1\">\n {#each item.children as child}\n <li>\n {#if child.children?.length}\n <details>\n <summary class=\"flex items-center gap-2 cursor-pointer list-none rounded-md px-2 py-2 hover:bg-primary/10\">\n <span class=\"flex-1\">{child.title}</span>\n <svg class=\"h-4 w-4\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"6 9 12 15 18 9\"/></svg>\n </summary>\n <ul class=\"mt-1 ml-3 space-y-1\">\n {#each child.children as leaf}\n <li><a href={leaf.href || '#'} target={leaf.external ? '_blank' : undefined} rel={leaf.external ? 'noreferrer' : undefined} class=\"block rounded-md px-2 py-2 hover:bg-primary/10\">{leaf.title}</a></li>\n {/each}\n </ul>\n </details>\n {:else}\n <a href={child.href || '#'} class=\"block rounded-md px-2 py-2 hover:bg-primary/10\">{child.title}</a>\n {/if}\n </li>\n {/each}\n </ul>\n </details>\n {:else}\n <a href={item.href || '#'} class=\"block rounded-md px-2 py-2 hover:bg-primary/10\">{item.title}</a>\n {/if}\n </li>\n {/each}\n </ul>\n </nav>\n {#if userItems.length}\n <div class=\"my-4 h-px bg-muted/20\"></div>\n <ul class=\"space-y-1\">\n {#each userItems as it}\n {#if it.divider}\n <li class=\"my-2 h-px bg-muted/20\"></li>\n {:else}\n <li><a href={it.href || '#'} class=\"block rounded-md px-2 py-2 hover:bg-primary/10\">{it.title}</a></li>\n {/if}\n {/each}\n </ul>\n {/if}\n </aside>\n </div>\n{/if}\n"
}
]
}

84
scripts/scaffold.ts

@ -0,0 +1,84 @@ @@ -0,0 +1,84 @@
#!/usr/bin/env -S deno run --allow-read --allow-write
import { dirname, resolve } from "jsr:@std/path";
/** Read and parse JSON */
async function readJson(p: string) {
try {
const txt = await Deno.readTextFile(p);
return JSON.parse(txt);
} catch (e) {
console.error(`Failed to read JSON: ${p}\n${e.message}`);
Deno.exit(1);
}
}
/** Ensure parent directory exists */
async function ensureDir(filePath: string) {
await Deno.mkdir(dirname(filePath), { recursive: true });
}
/** File exists? */
async function exists(p: string) {
try {
await Deno.lstat(p);
return true;
} catch (e) {
if (e instanceof Deno.errors.NotFound) return false;
throw e;
}
}
/** Write file if changed; return action marker */
async function writeFile(p: string, content: string) {
await ensureDir(p);
const had = await exists(p);
if (had) {
const current = await Deno.readTextFile(p);
if (current === content) return "skip" as const;
}
await Deno.writeTextFile(p, content);
return had ? ("update" as const) : ("create" as const);
}
/** Shallow-merge package.json fields */
function mergePackage(pkg: any, merge: any) {
const next = { ...pkg };
for (const key of ["scripts", "dependencies", "devDependencies"]) {
if (merge?.[key]) next[key] = { ...(pkg[key] || {}), ...merge[key] };
}
return next;
}
async function main() {
const manifestPath = Deno.args[0];
if (!manifestPath) {
console.error("Usage: deno run --allow-read --allow-write scripts/scaffold.ts <manifest.json>");
Deno.exit(1);
}
const manifest = await readJson(manifestPath);
// Optional: merge package.json if present
const pkgPath = resolve(Deno.cwd(), "package.json");
if (manifest.package?.merge && (await exists(pkgPath))) {
const pkg = await readJson(pkgPath);
const merged = mergePackage(pkg, manifest.package.merge);
await Deno.writeTextFile(pkgPath, JSON.stringify(merged, null, 2) + "\n");
console.log("✓ package.json merged");
} else if (manifest.package?.merge) {
console.log("• package.json not found — skipping merge");
}
// Write files from manifest
const files = manifest.files ?? [];
for (const f of files) {
const out = resolve(Deno.cwd(), String(f.path));
const action = await writeFile(out, String(f.content ?? ""));
const marker = action === "skip" ? "•" : action === "update" ? "↻" : "✓";
console.log(`${marker} ${f.path}`);
}
console.log("\nDone.");
}
if (import.meta.main) main();

200
src/app.css

@ -1,13 +1,137 @@ @@ -1,13 +1,137 @@
@import "tailwindcss";
/* then your own imports and layers */
@import "./styles/base.css";
@import "./styles/scrollbar.css";
@import "./styles/publications.css";
@import "./styles/visualize.css";
@import "./styles/asciidoc.css";
@import "./theme-tokens.css";
@plugin "flowbite/plugin";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
/* single color */
--color-highlight: #f9f6f1;
--color-border: var(--color-highlight);
--color-text-muted: var(--color-text-muted);
/* success */
--color-success-50: #e3f2e7;
--color-success-100: #c7e6cf;
--color-success-200: #a2d4ae;
--color-success-300: #7dbf8e;
--color-success-400: #5ea571;
--color-success-500: #4e8e5f;
--color-success-600: #3e744c;
--color-success-700: #305b3b;
--color-success-800: #22412a;
--color-success-900: #15281b;
/* info */
--color-info-50: #e7eff6;
--color-info-100: #c5d9ea;
--color-info-200: #9fbfdb;
--color-info-300: #7aa5cc;
--color-info-400: #5e90be;
--color-info-500: #4779a5;
--color-info-600: #365d80;
--color-info-700: #27445d;
--color-info-800: #192b3a;
--color-info-900: #0d161f;
/* warning */
--color-warning-50: #fef4e6;
--color-warning-100: #fde4bf;
--color-warning-200: #fcd18e;
--color-warning-300: #fbbc5c;
--color-warning-400: #f9aa33;
--color-warning-500: #f7971b;
--color-warning-600: #c97a14;
--color-warning-700: #9a5c0e;
--color-warning-800: #6c3e08;
--color-warning-900: #3e2404;
/* danger */
--color-danger-50: #fbeaea;
--color-danger-100: #f5cccc;
--color-danger-200: #eba5a5;
--color-danger-300: #e17e7e;
--color-danger-400: #d96060;
--color-danger-500: #c94848;
--color-danger-600: #a53939;
--color-danger-700: #7c2b2b;
--color-danger-800: #521c1c;
--color-danger-900: #2b0e0e;
}
/* Map Tailwind utilities → theme tokens (PRIMARY ONLY) */
@theme inline {
--color-primary-0: var(--brand-primary-0);
--color-primary-50: var(--brand-primary-50);
--color-primary-100: var(--brand-primary-100);
--color-primary-200: var(--brand-primary-200);
--color-primary-300: var(--brand-primary-300);
--color-primary-400: var(--brand-primary-400);
--color-primary-500: var(--brand-primary-500);
--color-primary-600: var(--brand-primary-600);
--color-primary-700: var(--brand-primary-700);
--color-primary-800: var(--brand-primary-800);
--color-primary-900: var(--brand-primary-900);
--color-primary-950: var(--brand-primary-950);
--color-primary-1000: var(--brand-primary-1000);
}
@source "../node_modules/flowbite-svelte/dist";
@source "../node_modules/flowbite-svelte-icons/dist";
/* @utility and @layer rules… */
/* .content-visibility-auto */
@utility content-visibility-auto { content-visibility: auto; }
/* .contain-size */
@utility contain-size { contain: size; }
/* numbers -> px (e.g. contain-intrinsic-w-[320] => width: 320px) */
@utility contain-intrinsic-w-* {
--tw-ciw: --value(number);
width: calc(var(--tw-ciw) * 1px);
}
@utility contain-intrinsic-h-* {
--tw-cih: --value(number);
height: calc(var(--tw-cih) * 1px);
}
/* percentages (e.g. contain-intrinsic-wp-[65%] => width: 65%) */
@utility contain-intrinsic-wp-* { width: --value(percentage); }
@utility contain-intrinsic-hp-* { height: --value(percentage); }
/* list-upper-alpha, list-lower-alpha (keep your old class names)
Note: in v4 you can also write list-[upper-alpha] / list-[lower-alpha] inline. */
@utility list-upper-alpha { list-style-type: upper-alpha; }
@utility list-lower-alpha { list-style-type: lower-alpha; }
/* flexGrow 1/2/3 — unlock grow-2, grow-3 (and any number via brackets) */
@utility grow-* { flex-grow: --value(integer); }
/* Hue rotate: use arbitrary values directly, e.g. hue-rotate-[20deg] (no config needed). */
/* --- Let Tailwind scan Flowbite Svelte (node_modules are ignored by default) --- */
@source "../node_modules/flowbite-svelte/dist";
@source "../node_modules/flowbite-svelte-icons/dist";
/* Custom styles */
@layer base {
/* disable chrome cancel button */
input[type="search"]::-webkit-search-cancel-button {
display: none;
}
.leather {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100;
@apply bg-primary-50 dark:bg-primary-1000 text-gray-900 dark:text-gray-100;
}
.btn-leather.text-xs {
@ -73,14 +197,18 @@ @@ -73,14 +197,18 @@
main.main-leather,
article.article-leather {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100;
@apply bg-primary-50 dark:bg-primary-1000 text-gray-900 dark:text-gray-100;
}
div.note-leather,
p.note-leather,
section.note-leather {
<<<<<<< HEAD
@apply bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100
p-2 rounded;
=======
@apply bg-primary-50 dark:bg-primary-1000 text-gray-900 dark:text-gray-100 p-2 rounded;
>>>>>>> 470a478 (Update, explode and break styles and components)
}
.edit div.note-leather:hover:not(:has(.note-leather:hover)),
@ -132,8 +260,12 @@ @@ -132,8 +260,12 @@
}
div.modal-leather > div {
<<<<<<< HEAD
@apply bg-primary-0 dark:bg-primary-950 border-b-[1px] border-primary-100
dark:border-primary-600;
=======
@apply bg-primary-50 dark:bg-primary-950 border-b-[1px] border-primary-100 dark:border-primary-600;
>>>>>>> 470a478 (Update, explode and break styles and components)
}
div.modal-leather > div > h1,
@ -147,9 +279,13 @@ @@ -147,9 +279,13 @@
}
div.modal-leather button {
<<<<<<< HEAD
@apply bg-primary-0 hover:bg-primary-0 dark:bg-primary-950
dark:hover:bg-primary-950 text-gray-900 hover:text-primary-600
dark:text-gray-100 dark:hover:text-primary-400;
=======
@apply bg-primary-50 hover:bg-primary-50 dark:bg-primary-950 dark:hover:bg-primary-950 text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400;
>>>>>>> 470a478 (Update, explode and break styles and components)
}
/* Navbar */
@ -158,7 +294,7 @@ @@ -158,7 +294,7 @@
}
nav.navbar-leather {
@apply bg-primary-0 dark:bg-primary-1000 z-10;
@apply bg-primary-50 dark:bg-primary-1000 z-10;
}
nav.navbar-leather svg {
@ -185,7 +321,7 @@ @@ -185,7 +321,7 @@
}
div.textarea-leather {
@apply bg-primary-0 dark:bg-primary-1000;
@apply bg-primary-50 dark:bg-primary-1000;
}
div.textarea-leather > div:nth-child(1),
@ -194,7 +330,7 @@ @@ -194,7 +330,7 @@
}
div.textarea-leather > div:nth-child(2) {
@apply bg-primary-0 dark:bg-primary-1000;
@apply bg-primary-50 dark:bg-primary-1000;
}
div.textarea-leather,
@ -349,9 +485,13 @@ @@ -349,9 +485,13 @@
/* Tooltip */
.tooltip-leather {
<<<<<<< HEAD
@apply fixed p-4 rounded shadow-lg bg-primary-0 dark:bg-primary-1000
text-gray-900 dark:text-gray-100 border border-gray-200
dark:border-gray-700 transition-colors duration-200;
=======
@apply fixed p-4 rounded shadow-lg bg-primary-50 dark:bg-primary-1000 text-gray-900 dark:text-gray-100 border border-gray-200 dark:border-gray-700 transition-colors duration-200;
>>>>>>> 470a478 (Update, explode and break styles and components)
max-width: 400px;
z-index: 1000;
}
@ -360,43 +500,21 @@ @@ -360,43 +500,21 @@
@apply dark:text-white;
}
/* Rendered publication content */
.publication-leather {
@apply flex flex-col space-y-4;
scroll-margin-top: 150px;
scroll-behavior: smooth;
h1,
h2,
h3,
h4,
h5,
h6 {
@apply h-leather;
}
h1 {
@apply h1-leather;
}
h2 {
@apply h2-leather;
}
/* common heading base styles */
h1,h2,h3,h4,h5,h6 { @apply text-gray-900 dark:text-gray-100 pt-4; }
h3 {
@apply h3-leather;
}
h4 {
@apply h4-leather;
}
h5 {
@apply h5-leather;
}
h6 {
@apply h6-leather;
/* sizes/weights per level */
h1 { @apply text-4xl font-bold; }
h2 { @apply text-3xl font-bold; }
h3 { @apply text-2xl font-bold; }
h4 { @apply text-xl font-bold; }
h5 { @apply text-lg font-semibold; }
h6 { @apply text-base font-semibold; }
}
div {
@ -407,7 +525,7 @@ @@ -407,7 +525,7 @@
@apply flex flex-col space-y-4;
ol {
@apply ol-leather list-decimal px-6 flex flex-col space-y-2;
@apply list-decimal px-6 flex flex-col space-y-2;
li {
.paragraph {
@ -421,7 +539,7 @@ @@ -421,7 +539,7 @@
@apply flex flex-col space-y-4;
ul {
@apply ul-leather list-disc px-6 flex flex-col space-y-2;
@apply list-disc px-6 flex flex-col space-y-2;
li {
.paragraph {
@ -432,7 +550,7 @@ @@ -432,7 +550,7 @@
}
a {
@apply link;
@apply underline cursor-pointer hover:text-primary-600 dark:hover:text-primary-400;
}
.imageblock {
@ -507,7 +625,7 @@ @@ -507,7 +625,7 @@
.note-leather .footnote-ref,
.note-leather .footnote-backref {
color: var(--color-leather-primary);
color: var(--color-primary);
}
/* Scrollable content */
@ -575,8 +693,6 @@ @@ -575,8 +693,6 @@
input[type="tel"],
input[type="url"],
textarea {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100
border-s-4 border-primary-200 rounded shadow-none px-4 py-2;
@apply focus:border-primary-600 dark:focus:border-primary-400;
}

19
src/lib/a/index.ts

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
export { default as AButton } from './primitives/AButton.svelte';
export { default as AInput } from './primitives/AInput.svelte';
export { default as ACard } from './primitives/ACard.svelte';
export { default as ASwitch } from './primitives/ASwitch.svelte';
export { default as ADetails } from './primitives/ADetails.svelte';
export { default as ANostrUser } from './primitives/ANostrUser.svelte';
export { default as ANostrBadge } from './primitives/ANostrBadge.svelte';
export { default as ANostrBadgeRow } from './primitives/ANostrBadgeRow.svelte';
export { default as AThemeToggleMini } from './primitives/AThemeToggleMini.svelte';
export { default as AReaderPage } from './reader/AReaderPage.svelte';
export { default as AReaderToolbar } from './reader/AReaderToolbar.svelte';
export { default as AReaderTOC } from './reader/AReaderTOC.svelte';
export { default as ATechToggle } from './reader/ATechToggle.svelte';
export { default as ATechBlock } from './reader/ATechBlock.svelte';
export { default as ATocNode } from './reader/ATocNode.svelte';
export { default as ANavbar } from './nav/ANavbar.svelte';
export { default as AFooter } from './nav/AFooter.svelte';

11
src/lib/a/nav/AFooter.svelte

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
<script>
import { Footer, FooterCopyright, FooterLink, FooterLinkGroup } from "flowbite-svelte";
</script>
<Footer class="mx-2">
<FooterCopyright href="/" by="GitCitadel" year={2025} />
<FooterLinkGroup class="mt-3 flex flex-wrap items-center text-sm text-gray-500 sm:mt-0 dark:text-gray-400">
<FooterLink href="/about">About</FooterLink>
<FooterLink href="/contact">Contact</FooterLink>
</FooterLinkGroup>
</Footer>

0
src/lib/a/nav/ANavDropdown.svelte

82
src/lib/a/nav/ANavbar.svelte

@ -0,0 +1,82 @@ @@ -0,0 +1,82 @@
<script lang="ts">
import {
DarkMode,
Navbar,
NavLi,
NavUl,
NavHamburger,
NavBrand,
Dropdown,
DropdownItem,
DropdownDivider
} from "flowbite-svelte";
import { siteNav, userMenu } from "$lib/nav/site-nav.js";
import { logoutUser, userStore } from "$lib/stores/userStore";
import Profile from "$components/util/Profile.svelte";
import { shortenBech32 } from "$lib/nostr/format.ts";
import type { NavItem } from "$lib/a/nav/nav-types.ts";
import { goto } from "$app/navigation";
import { ChevronDownOutline } from "flowbite-svelte-icons";
let {
currentPath = "",
}: {
currentPath?: string;
} = $props();
let userState = $derived($userStore);
function handleNavClick(item: NavItem) {
if (item.href) {
goto(item.href);
} else if (item.id === 'logout') {
logoutUser();
}
}
function flattenNavItems(navItems: NavItem[]): NavItem[] {
const result: NavItem[] = [];
for (const item of navItems) {
if (item.children && item.children.length > 0) {
result.push(...flattenNavItems(item.children));
} else {
result.push(item);
}
}
return result;
}
</script>
<Navbar class="flex flex-row" navContainerClass="w-full flex-row justify-between items-center">
<NavBrand href="/">
<h1>Alexandria</h1>
</NavBrand>
<div class="flex md:order-2">
<Profile isNav={true} pubkey={userState?.npub || undefined} />
<NavHamburger />
</div>
<NavUl class="order-1" activeUrl={currentPath}>
{#each siteNav as navSection}
{#if navSection.children && navSection.children.length > 0}
<NavLi class="cursor-pointer">
{navSection.title}<ChevronDownOutline class="text-primary-800 ms-2 inline h-6 w-6 dark:text-white" />
</NavLi>
<Dropdown simple class="w-44 z-20">
{#each flattenNavItems(navSection.children) as item}
<DropdownItem
href={item.href || undefined}
onclick={() => handleNavClick(item)}
>
{item.title}
</DropdownItem>
{/each}
</Dropdown>
{:else if navSection.href}
<NavLi href={navSection.href}>{navSection.title}</NavLi>
{/if}
{/each}
<NavLi>
<DarkMode class="btn-leather p-0" />
</NavLi>
</NavUl>
</Navbar>

41
src/lib/a/nav/AUserDropdown.svelte

@ -0,0 +1,41 @@ @@ -0,0 +1,41 @@
<script lang="ts">
import type { NavItem, UserInfo } from './nav-types';
import { onMount } from 'svelte';
let { user = null as UserInfo | null, items = [] as NavItem[], onselect = undefined as undefined | ((i:NavItem)=>void) } = $props();
let open = $state(false); let btn: HTMLButtonElement;
function onDoc(e: MouseEvent){ if (!open) return; if (!(e.target instanceof Node)) return; if (!btn?.parentElement?.contains(e.target)) open = false; }
onMount(()=>{ document.addEventListener('mousedown', onDoc); return () => document.removeEventListener('mousedown', onDoc); });
</script>
<div class="relative">
<button bind:this={btn} class="inline-flex items-center gap-2 h-9 px-2 rounded-md border border-muted/30 hover:bg-primary/10" aria-haspopup="menu" aria-expanded={open} onclick={() => (open = !open)}>
<img src={user?.avatarUrl || 'https://via.placeholder.com/24'} alt="" class="h-6 w-6 rounded-full object-cover" />
<span class="hidden sm:block text-sm">{user?.name || 'Account'}</span>
<svg class="h-4 w-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
{#if open}
<div class="absolute right-0 mt-1 min-w-[14rem] rounded-lg border border-muted/20 bg-surface shadow-lg py-1 z-50">
{#if user}
<div class="px-3 py-2 text-sm">
<div class="font-medium">{user.name}</div>
</div>
<div class="my-1 h-px bg-muted/20" ></div>
{/if}
<ul>
{#each items as it}
{#if it.divider}
<li class="my-1 h-px bg-muted/20"></li>
{:else}
<li>
<a href={it.href || '#'} target={it.external ? '_blank':undefined} rel={it.external ? 'noreferrer':undefined}
class="flex items-center gap-2 px-3 py-2 text-sm hover:bg-primary/10"
onclick={(e)=>{ if (!it.href || it.href==='#'){ e.preventDefault(); open=false; onselect?.(it); } else { open=false; } }}>
{it.title}
{#if it.badge}<span class="ml-auto text-[10px] rounded px-1.5 py-0.5 border border-primary/30 text-primary bg-primary/5">{it.badge}</span>{/if}
</a>
</li>
{/if}
{/each}
</ul>
</div>
{/if}
</div>

15
src/lib/a/nav/nav-types.ts

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
export type NavItem = {
id?: string;
title: string;
href?: string;
icon?: any;
badge?: string;
children?: NavItem[];
divider?: boolean;
external?: boolean;
};
export type UserInfo = {
name: string;
avatarUrl?: string;
};

39
src/lib/a/primitives/AButton.svelte

@ -0,0 +1,39 @@ @@ -0,0 +1,39 @@
<script lang="ts">
import { cva, twMerge } from '$lib/styles/cva';
let {
variant = 'solid',
size = 'md',
as = 'button',
disabled = false,
class: className = '',
// common attrs (no $$restProps in runes)
href = undefined as string | undefined,
target = undefined as string | undefined,
rel = undefined as string | undefined,
type = 'button',
onclick = undefined as undefined | ((e:MouseEvent)=>void)
} = $props();
const styles = cva(
'inline-flex items-center justify-center font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-primary/40 transition',
{ variants: { variant:
{ solid: 'bg-primary text-[rgb(var(--color-primary-contrast,255 255 255))] hover:bg-primary/90',
outline: 'border border-primary text-primary hover:bg-primary/10',
ghost: 'text-primary hover:bg-primary/10' },
size: { sm: 'h-8 px-3 text-sm', md: 'h-10 px-4', lg: 'h-12 px-5 text-lg' } },
defaultVariants: { variant: 'solid', size: 'md' } }
);
</script>
<svelte:element
this={as}
class={twMerge(styles({ variant, size }), className)}
disabled={as === 'button' ? disabled : undefined}
href={as === 'a' ? href : undefined}
target={as === 'a' ? target : undefined}
rel={as === 'a' ? rel : undefined}
type={as === 'button' ? type : undefined}
onclick={onclick}
>
<slot />
</svelte:element>

6
src/lib/a/primitives/ACard.svelte

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
<script lang="ts"> let { class: className = '' } = $props(); </script>
<div class={`rounded-lg border border-muted/20 bg-surface shadow-sm ${className}`}>
<slot name="header" />
<div class="p-4"><slot /></div>
<slot name="footer" />
</div>

16
src/lib/a/primitives/ADetails.svelte

@ -0,0 +1,16 @@ @@ -0,0 +1,16 @@
<script lang="ts">
import { showTech } from '$lib/stores/techStore';
let { summary = '', tech = false, defaultOpen = false, forceHide = false, class: className = '' } = $props();
let open = $derived(defaultOpen);
$effect(() => { if (tech && !$showTech) open = false; });
function onToggle(e: Event){ const el = e.currentTarget as HTMLDetailsElement; open = el.open; }
</script>
<details open={open} ontoggle={onToggle} class={`group rounded-lg border border-muted/20 bg-surface ${className}`} data-kind={tech ? 'tech':'general'}>
<summary class="flex items-center gap-2 cursor-pointer list-none px-3 py-2 rounded-lg select-none hover:bg-primary/10">
<svg class={`h-4 w-4 transition-transform ${open ? 'rotate-90':''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
<span class="font-medium">{summary}</span>
{#if tech}<span class="ml-2 text-[10px] uppercase tracking-wide rounded px-1.5 py-0.5 border border-primary/30 text-primary bg-primary/5">Technical</span>{/if}
<span class="ml-auto text-xs opacity-60 group-open:opacity-50">{open ? 'Hide':'Show'}</span>
</summary>
{#if !(tech && !$showTech && forceHide)}<div class="px-3 pb-3 pt-1 text-[0.95rem] leading-6"><slot /></div>{/if}
</details>

6
src/lib/a/primitives/AInput.svelte

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
<script lang="ts"> let { value = '', class: className = '', placeholder = '' } = $props(); </script>
<input
class={`w-full h-10 px-3 rounded-md border border-muted/30 bg-surface text-text placeholder:opacity-60 focus:outline-none focus:ring-2 focus:ring-primary/40 ${className}`}
bind:value
placeholder={placeholder}
/>

27
src/lib/a/primitives/ANostrBadge.svelte

@ -0,0 +1,27 @@ @@ -0,0 +1,27 @@
<script lang="ts">
import type { DisplayBadge } from '$lib/nostr/nip58';
export let badge: DisplayBadge;
export let size: 'xs' | 's' | 'm' | 'l' = 's';
const px = { xs: 16, s: 24, m: 32, l: 48 }[size];
</script>
<span class="inline-flex items-center" title={badge.title}>
{#if badge.thumbUrl}
<img
src={badge.thumbUrl}
alt={badge.title}
width={px}
height={px}
loading="lazy"
decoding="async"
class="rounded-md border border-muted/20 object-cover"
/>
{:else}
<span
class="grid place-items-center rounded-md border border-muted/20 bg-surface text-xs"
style={`width:${px}px;height:${px}px`}
>
{badge.title.slice(0, 1)}
</span>
{/if}
</span>

19
src/lib/a/primitives/ANostrBadgeRow.svelte

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
<script lang="ts">
import type { DisplayBadge } from '$lib/nostr/nip58';
import ANostrBadge from './ANostrBadge.svelte';
export let badges: DisplayBadge[] = [];
export let size: 'xs' | 's' | 'm' | 'l' = 's';
export let limit: number = 6;
const shown = () => badges.slice(0, limit);
</script>
<div class="flex flex-wrap gap-1.5 items-center">
{#each shown() as b (b.def.id)}
<ANostrBadge badge={b} {size} />
{/each}
{#if badges.length > limit}
<span class="text-[10px] px-1.5 py-0.5 rounded-md border border-muted/30 bg-surface/70">
+{badges.length - limit}
</span>
{/if}
</div>

113
src/lib/a/primitives/ANostrUser.svelte

@ -0,0 +1,113 @@ @@ -0,0 +1,113 @@
<script lang="ts">
import type { NostrProfile } from '$lib/nostr/types';
import type { DisplayBadge } from '$lib/nostr/nip58';
import ANostrBadgeRow from './ANostrBadgeRow.svelte';
import { shortenBech32, displayNameFrom } from '$lib/nostr/format';
import { verifyNip05 } from '$lib/nostr/nip05';
import { onMount } from 'svelte';
let {
npub, // required
pubkey = undefined as string | undefined,
profile = undefined as NostrProfile | undefined,
size = 'md' as 'sm' | 'md' | 'lg',
showNpub = true,
showBadges = true,
verifyNip05: doVerify = true,
nip05Verified = undefined as boolean | undefined,
nativeBadges = null as DisplayBadge[] | null,
badgeLimit = 6,
href = undefined as string | undefined,
class: className = ''
} = $props();
// Derived view-model
let displayName = displayNameFrom(npub, profile);
let shortNpub = shortenBech32(npub, true);
let avatarUrl = profile?.picture ?? '';
let nip05 = profile?.nip05 ?? '';
// NIP-05 verify
let computedVerified = $state(false);
let loadingVerify = $state(false);
onMount(async () => {
if (nip05Verified !== undefined) {
computedVerified = nip05Verified;
return;
}
if (!doVerify || !nip05 || !pubkey) return;
loadingVerify = true;
computedVerified = await verifyNip05(nip05, pubkey);
loadingVerify = false;
});
// Sizing map
const sizes = {
sm: { avatar: 'h-6 w-6', gap: 'gap-2', name: 'text-sm', meta: 'text-[11px]' },
md: { avatar: 'h-8 w-8', gap: 'gap-2.5', name: 'text-base',meta: 'text-xs' },
lg: { avatar: 'h-10 w-10',gap: 'gap-3', name: 'text-lg', meta: 'text-sm' }
}[size];
</script>
{#if href}
<a href={href} class={`inline-flex items-center ${sizes.gap} ${className}`}>
<Content />
</a>
{:else}
<div class={`inline-flex items-center ${sizes.gap} ${className}`}>
<Content />
</div>
{/if}
<!-- component content as a fragment (no extra <script> blocks, no JSX) -->
{#snippet Content()}
<span class={`shrink-0 rounded-full overflow-hidden bg-muted/20 border border-muted/30 ${sizes.avatar}`}>
{#if avatarUrl}
<img src={avatarUrl} alt="" class="h-full w-full object-cover" />
{:else}
<span class="h-full w-full grid place-items-center text-xs opacity-70">
{displayName.slice(0, 1).toUpperCase()}
</span>
{/if}
</span>
<span class="min-w-0">
<span class={`flex items-center gap-1 font-medium ${sizes.name}`}>
<span class="truncate">{displayName}</span>
{#if nip05 && (computedVerified || loadingVerify)}
<span class="inline-flex items-center"
title={computedVerified ? `NIP-05 verified: ${nip05}` : 'Verifying…'}>
{#if computedVerified}
<!-- Verified check -->
<svg class="h-4 w-4 text-primary" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6L9 17l-5-5" />
</svg>
{:else}
<!-- Loading ring -->
<svg class="h-4 w-4 animate-pulse opacity-70" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
</svg>
{/if}
</span>
{/if}
</span>
<span class={`flex items-center gap-2 text-muted/80 ${sizes.meta}`}>
{#if nip05}<span class="truncate" title={nip05}>{nip05}</span>{/if}
{#if showNpub}<span class="truncate opacity-80" title={npub}>{shortNpub}</span>{/if}
</span>
{#if showBadges}
<span class="mt-1 block">
<slot name="badges">
{#if nativeBadges}
<ANostrBadgeRow badges={nativeBadges} limit={badgeLimit} size="s" />
{/if}
</slot>
</span>
{/if}
</span>
{/snippet}

10
src/lib/a/primitives/ASwitch.svelte

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
<script lang="ts">
let { checked = false, class: className = '', onchange = undefined as undefined | ((v:boolean)=>void) } = $props();
function toggle(){ checked = !checked; onchange?.(checked); }
</script>
<button type="button" role="switch" aria-checked={checked}
onclick={toggle}
class={`inline-flex items-center h-6 w-11 rounded-full border border-muted/30 transition px-0.5 ${checked ? 'bg-primary' : 'bg-surface'} ${className}`}
>
<span class={`inline-block h-5 w-5 rounded-full bg-white shadow transform transition ${checked ? 'translate-x-5' : 'translate-x-0'}`} />
</button>

10
src/lib/a/primitives/AThemeToggleMini.svelte

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
<script>
let theme = $state('ocean'); // e.g. 'ocean' or '' for default
const apply = () => document.documentElement.setAttribute('data-theme', theme);
$effect(apply);
</script>
<select bind:value={theme} onchange={apply}>
<option value="">Default</option>
<option value="ocean">Ocean</option>
</select>

7
src/lib/a/reader/AReaderPage.svelte

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
<script lang="ts">
let { className = '' , content } = $props();
</script>
<article class="mx-auto max-w-measure font-reading leading-reading text-[1rem] {className}">
{@render content()}
</article>

70
src/lib/a/reader/AReaderTOC.svelte

@ -0,0 +1,70 @@ @@ -0,0 +1,70 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { TocItem } from './toc-utils';
import { ATocNode } from "$lib/a";
let {
items = [] as TocItem[],
activeId = null as string | null,
collapsible = true,
expandDepth = 1,
class: className = '',
onnavigate = undefined as undefined | ((href: string) => void)
} = $props();
let expanded = $state(new Set<string>());
let parentOf = new Map<string, string | null>();
function mapParents(list: TocItem[], parent: string | null = null) {
for (const it of list) {
parentOf.set(it.id, parent);
if (it.children?.length) mapParents(it.children, it.id);
}
}
function initExpansion(list: TocItem[], depth = 0) {
for (const it of list) {
if (depth < expandDepth) expanded.add(it.id);
if (it.children?.length) initExpansion(it.children, depth + 1);
}
expanded = new Set(expanded);
}
function expandAncestors(id: string | null) {
if (!id) return;
let cur: string | null | undefined = id;
while (cur) {
expanded.add(cur);
cur = parentOf.get(cur) ?? null;
}
expanded = new Set(expanded);
}
onMount(() => {
initExpansion(items, 0);
expandAncestors(activeId);
if (activeId) {
const el = document.querySelector(`[data-toc-id="${activeId}"]`);
if (el instanceof HTMLElement) el.scrollIntoView({ block: 'nearest' });
}
});
function toggle(id: string) {
if (!collapsible) return;
if (expanded.has(id)) expanded.delete(id);
else expanded.add(id);
expanded = new Set(expanded);
}
const pad = (depth: number) => `padding-left: calc(var(--space-4) * ${Math.max(depth, 0)})`;
const onNavigate = (href: string) => onnavigate?.(href);
</script>
<nav aria-label="Table of contents" class={`text-sm ${className}`}>
<ul class="space-y-1 max-h-[calc(100vh-6rem)] overflow-auto pr-1">
{#each items as item (item.id)}
<ATocNode {item} depth={0} {activeId} {collapsible} {expanded} {toggle} {pad} {onNavigate} />
{/each}
</ul>
</nav>

48
src/lib/a/reader/AReaderToolbar.svelte

@ -0,0 +1,48 @@ @@ -0,0 +1,48 @@
<script lang="ts">
import { theme, setTheme } from '$lib/theme/theme-store';
import AButton from '../primitives/AButton.svelte';
let size = 16;
let line = 1.7;
function applySize() {
document.documentElement.style.fontSize = size + 'px';
}
function incSize() {
size = Math.min(22, size + 1);
applySize();
}
function decSize() {
size = Math.max(14, size - 1);
applySize();
}
function incLine() {
line = Math.min(2, Math.round((line + 0.05) * 100) / 100);
document.documentElement.style.setProperty('--leading-reading', String(line));
}
function decLine() {
line = Math.max(1.4, Math.round((line - 0.05) * 100) / 100);
document.documentElement.style.setProperty('--leading-reading', String(line));
}
</script>
<div class="flex items-center gap-3 p-2 border-b border-muted/20 bg-surface sticky top-0 z-10">
<label class="text-sm opacity-70">Theme</label>
<select
class="h-9 px-2 rounded-md border border-muted/30 bg-surface"
bind:value={$theme}
on:change={(e) => setTheme((e.target as HTMLSelectElement).value)}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="sepia">Sepia</option>
</select>
<div class="mx-2 h-6 w-px bg-muted/30" />
<label class="text-sm opacity-70">Text size</label>
<AButton variant="outline" size="sm" on:click={decSize}>−</AButton>
<span class="text-sm w-8 text-center">{size}px</span>
<AButton variant="outline" size="sm" on:click={incSize}>+</AButton>
<div class="mx-2 h-6 w-px bg-muted/30" />
<label class="text-sm opacity-70">Line height</label>
<AButton variant="outline" size="sm" on:click={decLine}>−</AButton>
<span class="text-sm w-10 text-center">{line}</span>
<AButton variant="outline" size="sm" on:click={incLine}>+</AButton>
</div>

18
src/lib/a/reader/ATechBlock.svelte

@ -0,0 +1,18 @@ @@ -0,0 +1,18 @@
<script lang="ts">
import { showTech } from '$lib/stores/techStore.ts';
let revealed = false;
let { title = 'Technical details', className = '' , content} = $props();
let hidden = $derived(!$showTech && !revealed);
</script>
{#if hidden}
<div class="rounded-md border border-dashed border-muted/40 bg-surface/60 px-3 py-2 flex items-center gap-3 {className}">
<span class="text-xs opacity-70">{title} hidden</span>
<button class="ml-auto text-sm underline hover:no-underline" onclick={() => revealed = true}>Reveal this block</button>
</div>
{:else}
<div class="rounded-md border border-muted/20 bg-surface p-3 {className}">
{@render content()}
</div>
{/if}

11
src/lib/a/reader/ATechToggle.svelte

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
<script lang="ts">
import { showTech } from '$lib/stores/techStore.ts';
import ASwitch from '../primitives/ASwitch.svelte';
let label = 'Show technical details';
$: checked = $showTech;
</script>
<div class="inline-flex items-center gap-2 select-none">
<ASwitch bind:checked={$showTech} aria-label={label} />
<span class="text-sm">{label}</span>
</div>

32
src/lib/a/reader/ATocNode.svelte

@ -0,0 +1,32 @@ @@ -0,0 +1,32 @@
<script lang="ts">
import type { TocItem } from './toc-utils';
import TocNode from './ATocNode.svelte';
let { item, depth = 0, activeId = null as string | null, collapsible = true, expanded, toggle, pad, onNavigate }: { item: TocItem; depth?: number; activeId?: string|null; collapsible?: boolean; expanded: Set<string>; toggle: (id:string)=>void; pad: (depth:number)=>string; onNavigate: (href:string)=>void; } = $props();
const hasChildren = !!(item.children && item.children.length > 0);
let isOpen = $derived(expanded.has(item.id));
let isActive = $derived(activeId === item.id);
</script>
<li>
<div class="flex items-center gap-1 rounded-md hover:bg-primary/10" style={pad(depth)}>
{#if collapsible && hasChildren}
<button class="shrink-0 h-6 w-6 grid place-items-center rounded-md hover:bg-primary/10" aria-label={isOpen?`Collapse ${item.title}`:`Expand ${item.title}`} aria-expanded={isOpen} onclick={() => toggle(item.id)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={isOpen ? 'rotate-90 transition-transform' : 'transition-transform'}><path d="M9 18l6-6-6-6" /></svg>
</button>
{:else}
<span class="shrink-0 h-6 w-6" />
{/if}
<a href={item.href ?? `#${item.id}`} data-toc-id={item.id}
class="flex-1 min-w-0 rounded-md px-2 py-1.5 hover:bg-primary/10 focus:outline-none focus:ring-2 focus:ring-primary/40"
class:text-primary={isActive}
onclick={() => onNavigate(item.href ?? `#${item.id}`)}>
<span class="truncate">{item.title}</span>
</a>
</div>
{#if hasChildren}
<ul id={`sub-${item.id}`} aria-hidden={!isOpen} class={isOpen ? 'mt-1 space-y-1' : 'hidden'}>
{#each item.children as child (child.id)}
<TocNode item={child} depth={depth + 1} {activeId} {collapsible} {expanded} {toggle} {pad} {onNavigate} />
{/each}
</ul>
{/if}
</li>

1
src/lib/a/reader/scroll-spy.ts

@ -0,0 +1 @@ @@ -0,0 +1 @@
import { writable } from 'svelte/store'; export function createScrollSpy(ids:string[], opts:{ rootMargin?:string; threshold?:number[] }={}){ const active=writable<string|null>(null); let observer:IntersectionObserver|null=null; function start(){ if(typeof window==='undefined' || typeof IntersectionObserver==='undefined') return; observer=new IntersectionObserver((entries)=>{ const visible=entries.filter(e=>e.isIntersecting).sort((a,b)=>a.target.getBoundingClientRect().top - b.target.getBoundingClientRect().top); if(visible[0]) active.set((visible[0].target as HTMLElement).id); }, { rootMargin: opts.rootMargin ?? '-30% 0px -60% 0px', threshold: opts.threshold ?? [0,1] }); for(const id of ids){ const el=document.getElementById(id); if(el) observer.observe(el); } } function stop(){ observer?.disconnect(); observer=null; } return { active, start, stop }; }

4
src/lib/a/reader/toc-utils.ts

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
export type TocItem = { id:string; title:string; href?:string; children?: TocItem[]; };
const slugify=(s:string)=>s.toLowerCase().trim().replace(/[^\\w\\s-]/g,'').replace(/\\s+/g,'-').slice(0,80);
export function buildTocFromDocument(root:ParentNode=document, levels:number[]=[2,3,4]):TocItem[]{ const selector=levels.map(l=>`h${'${'}l}`).join(','); const nodes=Array.from(root.querySelectorAll(selector)) as HTMLElement[]; const toc:TocItem[]=[]; const stack:{level:number; item:TocItem}[]=[]; for(const el of nodes){ const level=Number(el.tagName.slice(1)); const text=(el.textContent||'').trim(); if(!text) continue; if(!el.id) el.id=slugify(text); const entry: TocItem = { id:el.id, title:text, href:'#'+el.id, children:[] }; while(stack.length && stack[stack.length-1].level>=level) stack.pop(); if(stack.length===0) toc.push(entry); else stack[stack.length-1].item.children!.push(entry); stack.push({level,item:entry}); } return toc; }
export function idsFromToc(items:TocItem[]):string[]{ const out:string[]=[]; const walk=(arr:TocItem[])=>{ for(const it of arr){ out.push(it.id); if(it.children?.length) walk(it.children); } }; walk(items); return out; }

4
src/lib/components/EventRenderLevelLimit.svelte

@ -42,14 +42,14 @@ @@ -42,14 +42,14 @@
id="levels-to-render"
min="1"
max="50"
class="w-20 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1 dark:text-white"
class="w-20 bg-primary-50 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1 dark:text-white"
bind:value={inputValue}
oninput={handleInput}
onkeydown={handleKeyDown}
/>
<button
onclick={handleUpdate}
class="px-3 py-1 bg-primary-0 dark:bg-primary-1000 border border-gray-400 dark:border-gray-600 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
class="px-3 py-1 bg-primary-50 dark:bg-primary-1000 border border-gray-400 dark:border-gray-600 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
>
Update
</button>

2
src/lib/components/publications/Publication.svelte

@ -252,7 +252,7 @@ @@ -252,7 +252,7 @@
{#if $publicationColumnVisibility.toc}
<Sidebar
activeUrl={`#${activeAddress ?? ""}`}
asideClass="fixed md:sticky top-[130px] sm:top-[146px] h-[calc(100vh-130px)] sm:h-[calc(100vh-146px)] z-10 bg-primary-0 dark:bg-primary-1000 px-5 w-80 left-0 pt-4 md:!pr-16 overflow-y-auto border border-l-4 rounded-lg border-primary-200 dark:border-primary-800 my-4"
asideClass="fixed md:sticky top-[130px] sm:top-[146px] h-[calc(100vh-130px)] sm:h-[calc(100vh-146px)] z-10 bg-primary-50 dark:bg-primary-1000 px-5 w-80 left-0 pt-4 md:!pr-16 overflow-y-auto border border-l-4 rounded-lg border-primary-200 dark:border-primary-800 my-4"
activeClass="flex items-center p-2 bg-primary-50 dark:bg-primary-800 p-2 rounded-lg"
nonActiveClass="flex items-center p-2 hover:bg-primary-50 dark:hover:bg-primary-800 p-2 rounded-lg"
>

2
src/lib/components/util/CardActions.svelte

@ -134,7 +134,7 @@ @@ -134,7 +134,7 @@
<Button
type="button"
id="dots-{event.id}"
class=" hover:bg-primary-0 dark:text-highlight dark:hover:bg-primary-800 p-1 dots"
class=" hover:bg-primary-50 dark:text-highlight dark:hover:bg-primary-800 p-1 dots"
color="none"
data-popover-target="popover-actions"
>

40
src/lib/nav/site-nav.ts

@ -0,0 +1,40 @@ @@ -0,0 +1,40 @@
import type { NavItem } from '$lib/a/nav/nav-types';
export const siteNav: NavItem[] = [
{
title: 'Create',
children: [
{ title: 'Compose notes', href: '/new/compose' },
{ title: 'Publish events', href: '/events/publish' },
]
},
{
title: 'Explore',
children: [
{ title: 'Publications', children: [
{ title: 'Publications', href: '/' },
{ title: 'My Notes', href: '/my-notes' },
{ title: 'Events', href: '/events' },
{ title: 'Visualize', href: '/visualize' }
]
}
]
},
{
title: 'About',
children: [
{ title: 'Onboarding', children: [{ title: 'Getting Started', href: '/start' }] },
{ title: 'Project', children: [
{ title: 'About', href: '/about' },
{ title: 'Contact', href: '/contact' }
] }
]
}
];
export const userMenu: NavItem[] = [
{ title: 'Profile', href: '/me' },
{ title: 'Settings', href: '/settings' },
{ divider: true, title: '' },
{ id: 'logout', title: 'Sign out' } // <-- no href => action item
];

2
src/lib/navigator/EventNetwork/Legend.svelte

@ -258,7 +258,7 @@ @@ -258,7 +258,7 @@
id="tag-type-select"
bind:value={selectedTagType}
onchange={onTagSettingsChange}
class="w-full text-xs bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1 dark:text-white mt-1"
class="w-full text-xs bg-primary-50 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1 dark:text-white mt-1"
>
<option value="t">Hashtags</option>
<option value="author">Authors</option>

1
src/lib/nostr/event.ts

@ -0,0 +1 @@ @@ -0,0 +1 @@
export type NostrEvent = { id:string; kind:number; pubkey:string; created_at:number; tags:string[][]; content:string; }; export type AddressPointer = string;

1
src/lib/nostr/format.ts

@ -0,0 +1 @@ @@ -0,0 +1 @@
export function shortenBech32(id:string, keepPrefix=true, head=8, tail=6){ if(!id) return ''; const i=id.indexOf('1'); const prefix=i>0? id.slice(0,i):''; const data=i>0? id.slice(i+1): id; const short = data.length>head+tail ? `${'${'}data.slice(0,head)}…${'${'}data.slice(-tail)}` : data; return keepPrefix && prefix ? `${'${'}prefix}1${'${'}short}` : short; } export function displayNameFrom(npub:string, p?:{ name?:string; display_name?:string; nip05?:string }){ return (p?.display_name?.trim() || p?.name?.trim() || (p?.nip05 && p.nip05.split('@')[0]) || shortenBech32(npub,true)); }

1
src/lib/nostr/nip05.ts

@ -0,0 +1 @@ @@ -0,0 +1 @@
export async function verifyNip05(nip05:string, pubkeyHex:string):Promise<boolean>{ try{ if(!nip05||!pubkeyHex) return false; const [name,domain]=nip05.toLowerCase().split('@'); if(!name||!domain) return false; const url=`https://${'${'}domain}/.well-known/nostr.json?name=${'${'}encodeURIComponent(name)}`; const res=await fetch(url,{ headers:{ Accept:'application/json' } }); if(!res.ok) return false; const json=await res.json(); const found=json?.names?.[name]; return typeof found==='string' && found.toLowerCase()===pubkeyHex.toLowerCase(); }catch{ return false; } }

1
src/lib/nostr/nip58.ts

@ -0,0 +1 @@ @@ -0,0 +1 @@
import type { NostrEvent, AddressPointer } from './event'; export type BadgeDefinition={ kind:30009; id:string; pubkey:string; d:string; a:AddressPointer; name?:string; description?:string; image?:{ url:string; size?:string }|null; thumbs:{ url:string; size?:string }[]; }; export type BadgeAward={ kind:8; id:string; pubkey:string; a:AddressPointer; recipients:{ pubkey:string; relay?:string }[]; }; export type ProfileBadges={ kind:30008; id:string; pubkey:string; pairs:{ a:AddressPointer; awardId:string; relay?:string }[]; }; export const isKind=(e:NostrEvent,k:number)=>e.kind===k; const val=(tags:string[][],name:string)=>tags.find(t=>t[0]===name)?.[1]; const vals=(tags:string[][],name:string)=>tags.filter(t=>t[0]===name).map(t=>t.slice(1)); export function parseBadgeDefinition(e:NostrEvent):BadgeDefinition|null{ if(e.kind!==30009) return null; const d=val(e.tags,'d'); if(!d) return null; const a:AddressPointer=`30009:${'${'}e.pubkey}:${'${'}d}`; const name=val(e.tags,'name')||undefined; const description=val(e.tags,'description')||undefined; const imageTag=vals(e.tags,'image')[0]; const image=imageTag? { url:imageTag[0], size:imageTag[1] }: null; const thumbs=vals(e.tags,'thumb').map(([url,size])=>({ url, size })); return { kind:30009, id:e.id, pubkey:e.pubkey, d, a, name, description, image, thumbs }; } export function parseBadgeAward(e:NostrEvent):BadgeAward|null{ if(e.kind!==8) return null; const atag=vals(e.tags,'a')[0]; if(!atag) return null; const a:AddressPointer=atag[0]; const recipients=vals(e.tags,'p').map(([pubkey,relay])=>({ pubkey, relay })); return { kind:8, id:e.id, pubkey:e.pubkey, a, recipients }; } export function parseProfileBadges(e:NostrEvent):ProfileBadges|null{ if(e.kind!==30008) return null; const d=val(e.tags,'d'); if(d!=='profile_badges') return null; const pairs: { a:AddressPointer; awardId:string; relay?:string }[]=[]; for(let i=0;i<e.tags.length;i++){ const t=e.tags[i]; if(t[0]==='a'){ const a=t[1]; const nxt=e.tags[i+1]; if(nxt && nxt[0]==='e'){ pairs.push({ a, awardId:nxt[1], relay:nxt[2] }); i++; } } } return { kind:30008, id:e.id, pubkey:e.pubkey, pairs }; } export type DisplayBadge={ def:BadgeDefinition; award:BadgeAward|null; issuer:string; thumbUrl:string|null; title:string; }; export function pickThumb(def:BadgeDefinition, prefer:( '16'|'32'|'64'|'256'|'512')[]=['32','64','256']):string|null{ for(const p of prefer){ const t=def.thumbs.find(t=>(t.size||'').startsWith(p+'x')); if(t) return t.url; } return def.image?.url || null; } export function buildDisplayBadgesForUser(userPubkey:string, defs:BadgeDefinition[], awards:BadgeAward[], profileBadges?:ProfileBadges|null, opts:{ issuerWhitelist?:Set<string>; max?:number }={}):DisplayBadge[]{ const byA=new Map<string,BadgeDefinition>(defs.map(d=>[d.a,d])); const byAwardId=new Map<string,BadgeAward>(awards.map(a=>[a.id,a])); const isWhitelisted=(issuer:string)=>!opts.issuerWhitelist || opts.issuerWhitelist.has(issuer); let out:DisplayBadge[]=[]; if(profileBadges && profileBadges.pubkey===userPubkey){ for(const {a,awardId} of profileBadges.pairs){ const def=byA.get(a); if(!def) continue; const award=byAwardId.get(awardId)||null; if(award && (award.a!==a || !award.recipients.find(r=>r.pubkey===userPubkey))) continue; if(!isWhitelisted(def.pubkey)) continue; out.push({ def, award, issuer:def.pubkey, thumbUrl: pickThumb(def), title: def.name || def.d }); } } else { for(const aw of awards){ if(!aw.recipients.find(r=>r.pubkey===userPubkey)) continue; const def=byA.get(aw.a); if(!def) continue; if(!isWhitelisted(def.pubkey)) continue; out.push({ def, award:aw, issuer:def.pubkey, thumbUrl: pickThumb(def), title: def.name || def.d }); } } if(opts.max && out.length>opts.max) out=out.slice(0,opts.max); return out; }

1
src/lib/nostr/types.ts

@ -0,0 +1 @@ @@ -0,0 +1 @@
export type NostrProfile = { name?:string; display_name?:string; picture?:string; about?:string; nip05?:string; lud16?:string; badges?: Array<{ label:string; color?:string }>; };

5
src/lib/stores/techStore.ts

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
import { writable } from 'svelte/store';
const KEY='showTech';
const initial = typeof localStorage!=='undefined' ? localStorage.getItem(KEY)==='true' : true;
export const showTech = writable<boolean>(initial);
showTech.subscribe(v=>{ if(typeof document!=='undefined'){ document.documentElement.dataset.tech = v ? 'on' : 'off'; localStorage.setItem(KEY,String(v)); } });

3
src/lib/styles/cva.ts

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { twMerge } from 'tailwind-merge';
export { cva, twMerge, type VariantProps };

32
src/lib/theme/build-tokens.ts

@ -0,0 +1,32 @@ @@ -0,0 +1,32 @@
#!/usr/bin/env -S deno run --allow-read --allow-write
import { join } from "https://deno.land/std@0.224.0/path/mod.ts";
import { parse as parseYaml } from "https://deno.land/std@0.224.0/yaml/mod.ts";
const themesDir = "src/lib/theme/themes";
const outCss = "src/lib/theme/generated/themes.css";
function toRgb(hex: string) {
const h = hex.replace("#", "").trim();
const n = (s: string) => parseInt(s, 16);
if (h.length === 3) return `${n(h[0]+h[0])} ${n(h[1]+h[1])} ${n(h[2]+h[2])}`;
return `${n(h.slice(0,2))} ${n(h.slice(2,4))} ${n(h.slice(4,6))}`;
}
const entries: string[] = [];
for await (const ent of Deno.readDir(themesDir)) if (ent.isFile && ent.name.endsWith(".yaml")) entries.push(ent.name);
entries.sort();
let css = "";
for (const file of entries) {
const t = parseYaml(await Deno.readTextFile(join(themesDir, file))) as any;
const sel = t.name === "light" ? ':root,[data-theme="light"]' : `[data-theme="${t.name}"]`;
css += `${sel}{\n`;
for (const [k, v] of Object.entries(t.colors ?? {})) css += `--color-${k}: ${toRgb(String(v))};\n`;
for (const [k, v] of Object.entries(t.radii ?? {})) css += `--radius-${k}: ${v};\n`;
for (const [k, v] of Object.entries(t.spacing ?? {})) css += `--space-${k}: ${v};\n`;
const ty = t.typography ?? {};
if (ty["font-reading"]) css += `--font-reading: ${ty["font-reading"]};\n`;
if (ty["font-ui"]) css += `--font-ui: ${ty["font-ui"]};\n`;
if (ty["leading-reading"]) css += `--leading-reading: ${ty["leading-reading"]};\n`;
if (ty["measure-ch"]) css += `--measure-ch: ${ty["measure-ch"]};\n`;
css += `}\n\n`;
}
await Deno.mkdir(join("src/lib/theme/generated"), { recursive: true });
await Deno.writeTextFile(outCss, css);
console.log("Wrote", outCss);

93
src/lib/theme/generated/themes.css

@ -0,0 +1,93 @@ @@ -0,0 +1,93 @@
[data-theme="dark"]{
--color-bg: 11 16 32;
--color-surface: 15 20 38;
--color-text: 229 231 235;
--color-muted: 148 163 184;
--color-primary: 96 165 250;
--color-primary-50: #fff5f2;
--color-primary-100: #fff1ee;
--color-primary-200: #ffe4de;
--color-primary-300: #ffd5cc;
--color-primary-400: #ffbcad;
--color-primary-500: #fe795d;
--color-primary-600: #ef562f;
--color-primary-700: #eb4f27;
--color-primary-800: #cc4522;
--color-primary-900: #a5371b;
--color-secondary-50: #f0f9ff;
--color-secondary-100: #e0f2fe;
--color-secondary-200: #bae6fd;
--color-secondary-300: #7dd3fc;
--color-secondary-400: #38bdf8;
--color-secondary-500: #0ea5e9;
--color-secondary-600: #0284c7;
--color-secondary-700: #0369a1;
--color-secondary-800: #075985;
--color-secondary-900: #0c4a6e;
--color-primary-contrast: 11 16 32;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 14px;
--radius-full: 9999px;
--space-0: 0;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
--font-reading: Literata, ui-serif, Georgia, serif;
--font-ui: Inter, ui-sans-serif, system-ui, sans-serif;
--leading-reading: 1.8;
--measure-ch: 68;
}
:root,[data-theme="light"]{
--color-bg: 255 255 255;
--color-surface: 250 250 250;
--color-text: 17 24 39;
--color-muted: 107 114 128;
--color-primary: 29 78 216;
--color-primary-contrast: 255 255 255;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 14px;
--radius-full: 9999px;
--space-0: 0;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
--font-reading: Literata, ui-serif, Georgia, serif;
--font-ui: Inter, ui-sans-serif, system-ui, sans-serif;
--leading-reading: 1.7;
--measure-ch: 70;
}
[data-theme="sepia"]{
--color-bg: 245 236 217;
--color-surface: 249 243 231;
--color-text: 58 47 30;
--color-muted: 109 92 70;
--color-primary: 138 90 68;
--color-primary-contrast: 255 255 255;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 14px;
--radius-full: 9999px;
--space-0: 0;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
--font-reading: Literata, ui-serif, Georgia, serif;
--font-ui: Inter, ui-sans-serif, system-ui, sans-serif;
--leading-reading: 1.7;
--measure-ch: 70;
}

6
src/lib/theme/theme-store.ts

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
import { writable } from 'svelte/store';
const KEY='theme';
const initial = (typeof localStorage!=='undefined' && localStorage.getItem(KEY)) || 'light';
export const theme = writable(initial);
theme.subscribe(v=>{ if(typeof document!=='undefined'){ document.documentElement.dataset.theme=String(v); localStorage.setItem(KEY,String(v)); } });
export const setTheme = (t:string)=>theme.set(t);

15
src/lib/theme/themes/dark.yaml

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
name: dark
colors:
bg: '#0B1020'
surface: '#0F1426'
text: '#E5E7EB'
muted: '#94A3B8'
primary: '#60A5FA'
primary-contrast: '#0B1020'
radii: { sm: 4px, md: 8px, lg: 14px, full: 9999px }
spacing: { 0: 0, 1: 0.25rem, 2: 0.5rem, 3: 0.75rem, 4: 1rem, 6: 1.5rem, 8: 2rem }
typography:
font-reading: 'Literata, ui-serif, Georgia, serif'
font-ui: 'Inter, ui-sans-serif, system-ui, sans-serif'
leading-reading: 1.8
measure-ch: 68

15
src/lib/theme/themes/light.yaml

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
name: light
colors:
bg: '#FFFFFF'
surface: '#FAFAFA'
text: '#111827'
muted: '#6B7280'
primary: '#1D4ED8'
primary-contrast: '#FFFFFF'
radii: { sm: 4px, md: 8px, lg: 14px, full: 9999px }
spacing: { 0: 0, 1: 0.25rem, 2: 0.5rem, 3: 0.75rem, 4: 1rem, 6: 1.5rem, 8: 2rem }
typography:
font-reading: 'Literata, ui-serif, Georgia, serif'
font-ui: 'Inter, ui-sans-serif, system-ui, sans-serif'
leading-reading: 1.7
measure-ch: 70

15
src/lib/theme/themes/sepia.yaml

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
name: sepia
colors:
bg: '#F5ECD9'
surface: '#F9F3E7'
text: '#3A2F1E'
muted: '#6D5C46'
primary: '#8A5A44'
primary-contrast: '#FFFFFF'
radii: { sm: 4px, md: 8px, lg: 14px, full: 9999px }
spacing: { 0: 0, 1: 0.25rem, 2: 0.5rem, 3: 0.75rem, 4: 1rem, 6: 1.5rem, 8: 2rem }
typography:
font-reading: 'Literata, ui-serif, Georgia, serif'
font-ui: 'Inter, ui-sans-serif, system-ui, sans-serif'
leading-reading: 1.7
measure-ch: 70

16
src/routes/+layout.svelte

@ -1,12 +1,12 @@ @@ -1,12 +1,12 @@
<script lang="ts">
import "../app.css";
import Navigation from "$lib/components/Navigation.svelte";
import { onMount, setContext } from "svelte";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { cleanupNdk, getPersistedLogin } from "$lib/ndk";
import { userStore, loginMethodStorageKey } from "$lib/stores/userStore";
import type { LayoutProps } from "./$types";
import { page } from "$app/state";
import { ANavbar, AFooter } from "$lib/a/index.js";
// Define children prop for Svelte 5
let { data, children }: LayoutProps = $props();
@ -15,7 +15,8 @@ @@ -15,7 +15,8 @@
// Get standard metadata for OpenGraph tags
let title = "Library of Alexandria";
let currentUrl = $page.url.href;
let currentUrl = page.url.href;
let currentPath = page.url.pathname;
// Get default image and summary for the Alexandria website
let image = "/screenshots/old_books.jpg";
@ -181,7 +182,12 @@ @@ -181,7 +182,12 @@
<meta name="twitter:image" content={image} />
</svelte:head>
<div class={"leather mt-[120px] w-full mx-auto flex flex-col items-center"}>
<Navigation class="fixed top-0" />
<div class="min-h-screen flex flex-col">
<ANavbar {currentPath} />
<main class="flex-1 w-full">
{@render children()}
</main>
<AFooter />
</div>

8
src/routes/+page.svelte

@ -38,14 +38,12 @@ @@ -38,14 +38,12 @@
function confirmClearSearch() {
searchQuery = "";
showClearSearchModal = false;
// Force the state update by reassigning
showOnlyMyPublications = false;
showOnlyMyPublications = true;
showOnlyMyPublications = pendingCheckboxState;
}
function cancelClearSearch() {
// Don't change showOnlyMyPublications - it should remain as it was
showClearSearchModal = false;
pendingCheckboxState = false;
}
// AI-NOTE: Removed automatic search clearing - now handled with confirmation dialog
@ -71,7 +69,7 @@ @@ -71,7 +69,7 @@
<div class="flex items-center gap-2">
<input
type="checkbox"
checked={showOnlyMyPublications}
bind:checked={showOnlyMyPublications}
onchange={handleCheckboxChange}
id="show-my-publications"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"

2
src/routes/contact/+page.svelte

@ -385,7 +385,7 @@ @@ -385,7 +385,7 @@
<div class="absolute inset-0 overflow-hidden">
<Textarea
id="content"
class="w-full h-full resize-none bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100 border-s-4 border-primary-200 rounded-b-lg rounded-t-none shadow-none px-4 py-2 focus:border-primary-600 dark:focus:border-primary-400"
class="w-full h-full resize-none bg-primary-50 dark:bg-primary-1000 text-gray-900 dark:text-gray-100 border-s-4 border-primary-200 rounded-b-lg rounded-t-none shadow-none px-4 py-2 focus:border-primary-600 dark:focus:border-primary-400"
bind:value={content}
required
placeholder="Describe your issue in detail...

2
src/styles/base.css

@ -4,6 +4,6 @@ @@ -4,6 +4,6 @@
@layer components {
body {
@apply bg-primary-0 dark:bg-primary-1000;
@apply bg-primary-50 dark:bg-primary-1000;
}
}

40
src/theme-tokens.css

@ -0,0 +1,40 @@ @@ -0,0 +1,40 @@
/* Default theme (your current palette) */
:root {
--brand-primary-0: #efe6dc;
--brand-primary-50: #decdb9;
--brand-primary-100: #d6c1a8;
--brand-primary-200: #c6a885;
--brand-primary-300: #b58f62;
--brand-primary-400: #ad8351;
--brand-primary-500: #c6a885;
--brand-primary-600: #795c39;
--brand-primary-700: #564a3e;
--brand-primary-800: #3c352c;
--brand-primary-900: #2a241c;
--brand-primary-950: #1d1812;
--brand-primary-1000: #15110d;
}
/* Example alternative theme: ocean */
:root[data-theme="ocean"] {
--brand-primary-0: #ecf8ff;
--brand-primary-50: #e6f3ff;
--brand-primary-100: #d9ecff;
--brand-primary-200: #b9ddff;
--brand-primary-300: #90cbff;
--brand-primary-400: #61b6fb;
--brand-primary-500: #0ea5e9; /* sky-500-ish */
--brand-primary-600: #0284c7;
--brand-primary-700: #0369a1;
--brand-primary-800: #075985;
--brand-primary-900: #0c4a6e;
--brand-primary-950: #082f49;
--brand-primary-1000: #062233;
}
/* (Optional) per-theme dark tweaks — applied when <html class="dark"> is set */
:root.dark[data-theme="ocean"] {
/* nudge the mid tones brighter for contrast */
--brand-primary-400: #7ccdfc;
--brand-primary-500: #38bdf8;
}

123
tailwind.config.cjs

@ -1,123 +0,0 @@ @@ -1,123 +0,0 @@
import flowbite from "flowbite/plugin";
import plugin from "tailwindcss/plugin";
import typography from "@tailwindcss/typography";
/** @type {import('tailwindcss').Config}*/
const config = {
content: [
"./src/**/*.{html,js,svelte,ts}",
"./node_modules/flowbite/**/*.js",
"./node_modules/flowbite-svelte/**/*.{html,js,svelte,ts}",
],
theme: {
extend: {
colors: {
highlight: "#f9f6f1",
primary: {
0: "#efe6dc",
50: "#decdb9",
100: "#d6c1a8",
200: "#c6a885",
300: "#b58f62",
400: "#ad8351",
500: "#c6a885",
600: "#795c39",
700: "#564a3e",
800: "#3c352c",
900: "#2a241c",
950: "#1d1812",
1000: "#15110d",
},
success: {
50: "#e3f2e7",
100: "#c7e6cf",
200: "#a2d4ae",
300: "#7dbf8e",
400: "#5ea571",
500: "#4e8e5f",
600: "#3e744c",
700: "#305b3b",
800: "#22412a",
900: "#15281b",
},
info: {
50: "#e7eff6",
100: "#c5d9ea",
200: "#9fbfdb",
300: "#7aa5cc",
400: "#5e90be",
500: "#4779a5",
600: "#365d80",
700: "#27445d",
800: "#192b3a",
900: "#0d161f",
},
warning: {
50: "#fef4e6",
100: "#fde4bf",
200: "#fcd18e",
300: "#fbbc5c",
400: "#f9aa33",
500: "#f7971b",
600: "#c97a14",
700: "#9a5c0e",
800: "#6c3e08",
900: "#3e2404",
},
danger: {
50: "#fbeaea",
100: "#f5cccc",
200: "#eba5a5",
300: "#e17e7e",
400: "#d96060",
500: "#c94848",
600: "#a53939",
700: "#7c2b2b",
800: "#521c1c",
900: "#2b0e0e",
},
},
listStyleType: {
"upper-alpha": "upper-alpha", // Uppercase letters
"lower-alpha": "lower-alpha", // Lowercase letters
},
flexGrow: {
1: "1",
2: "2",
3: "3",
},
hueRotate: {
20: "20deg",
},
},
},
plugins: [
flowbite(),
typography,
plugin(function ({ addUtilities, matchUtilities }) {
addUtilities({
".content-visibility-auto": {
"content-visibility": "auto",
},
".contain-size": {
contain: "size",
},
});
matchUtilities({
"contain-intrinsic-w-*": (value) => ({
width: value,
}),
"contain-intrinsic-h-*": (value) => ({
height: value,
}),
});
}),
],
darkMode: "class",
};
module.exports = config;

2
vite.config.ts

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
import { execSync } from "child_process";
import { execSync } from "node:child_process";
import process from "node:process";
// Function to get the latest git tag

Loading…
Cancel
Save