Initial Daybook MVP

This commit is contained in:
peter 2026-06-10 15:48:19 +01:00
commit 3974276c9b
25 changed files with 1647 additions and 0 deletions

33
.gitignore vendored Normal file
View file

@ -0,0 +1,33 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
.Python
# Virtualenvs
.venv/
venv/
env/
# Django/local runtime
db.sqlite3
artifacts_store/
django-runserver.log
django-runserver.err.log
# Static/build/cache
staticfiles/
.coverage
htmlcov/
.pytest_cache/
# Secrets/env
.env
.env.*
# IDE/OS
.idea/
.vscode/
.DS_Store
Thumbs.db

52
AGENTS.md Normal file
View file

@ -0,0 +1,52 @@
# AGENTS.md
## Project
Daybook is a self-hosted Django + Django REST Framework web app where AI agents publish artifacts and a human browses them in a time-ordered, persona-filterable feed.
The core product principle is: agents publish content, not files. The server owns IDs, styling, storage layout, dates, and normalization into browser-native HTML, image, or PDF outputs.
## Current MVP Decisions
- Use a single shared ingest API token for now.
- Require `persona` in every artifact payload.
- Do not add reader authentication in the local/self-hosted MVP.
- Use random 8-character artifact IDs.
- Start as a plain Django project; add Docker after core ingest and viewer work.
- Preserve original markdown as `.md` alongside rendered `.html`.
## Architecture Notes
- Planning source: `planning/daybook_planning.md`.
- Django project: `daybook_project/`.
- Main app: `artifacts/`.
- Theme CSS: `static/themes/`.
- Feed template: `templates/artifacts/feed.html`.
- Stored artifact files live under `artifacts_store/{persona}/{YYYY-MM-DD}/`.
## Implementation Guidelines
- Prefer small, direct changes over broad abstractions.
- Keep ingest normalization server-side.
- The viewer should only need to render HTML, image, or PDF.
- Do not make agents responsible for styling, filenames, or storage paths.
- Preserve existing planning decisions unless the user explicitly changes them.
- Avoid adding backwards-compatibility code before anything has shipped.
## Commands
- Install dependencies: `pip install -r requirements.txt`
- Run migrations: `python manage.py migrate`
- Start dev server: `python manage.py runserver`
- Django checks: `python manage.py check`
## API Defaults
- MVP ingest endpoint: `POST /api/artifacts`
- MVP list endpoint: `GET /api/artifacts?persona=&day=&tag=`
- Artifact view: `GET /a/{id}`
- Development token: `Authorization: Bearer dev-token`
## OpenCode
This project is initialized with `opencode.json`, which loads this file as project instructions. After changing `opencode.json`, `.opencode/`, or this file, restart OpenCode so the updated instructions are loaded.

0
artifacts/__init__.py Normal file
View file

10
artifacts/admin.py Normal file
View file

@ -0,0 +1,10 @@
from django.contrib import admin
from .models import Artifact
@admin.register(Artifact)
class ArtifactAdmin(admin.ModelAdmin):
list_display = ("id", "title", "persona", "kind", "day", "created_at")
list_filter = ("persona", "kind", "day")
search_fields = ("id", "title", "persona")

6
artifacts/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ArtifactsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "artifacts"

View file

@ -0,0 +1,27 @@
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Artifact",
fields=[
("id", models.CharField(max_length=8, primary_key=True, serialize=False)),
("persona", models.CharField(db_index=True, max_length=80)),
("title", models.CharField(max_length=200)),
("kind", models.CharField(choices=[("html_fragment", "HTML fragment"), ("html_full", "Full HTML"), ("markdown", "Markdown"), ("deck", "Deck"), ("image", "Image"), ("pdf", "PDF"), ("binary", "Binary")], max_length=20)),
("theme", models.CharField(blank=True, max_length=40)),
("day", models.DateField(db_index=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("path", models.CharField(max_length=500)),
("original_kind", models.CharField(max_length=20)),
("tags", models.JSONField(blank=True, default=list)),
("source_prompt", models.TextField(blank=True)),
],
options={"ordering": ["-created_at"]},
),
]

View file

39
artifacts/models.py Normal file
View file

@ -0,0 +1,39 @@
from django.db import models
class Artifact(models.Model):
HTML_FRAGMENT = "html_fragment"
HTML_FULL = "html_full"
MARKDOWN = "markdown"
DECK = "deck"
IMAGE = "image"
PDF = "pdf"
BINARY = "binary"
KIND_CHOICES = [
(HTML_FRAGMENT, "HTML fragment"),
(HTML_FULL, "Full HTML"),
(MARKDOWN, "Markdown"),
(DECK, "Deck"),
(IMAGE, "Image"),
(PDF, "PDF"),
(BINARY, "Binary"),
]
id = models.CharField(max_length=8, primary_key=True)
persona = models.CharField(max_length=80, db_index=True)
title = models.CharField(max_length=200)
kind = models.CharField(max_length=20, choices=KIND_CHOICES)
theme = models.CharField(max_length=40, blank=True)
day = models.DateField(db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
path = models.CharField(max_length=500)
original_kind = models.CharField(max_length=20)
tags = models.JSONField(default=list, blank=True)
source_prompt = models.TextField(blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"{self.title} ({self.persona})"

10
artifacts/urls.py Normal file
View file

@ -0,0 +1,10 @@
from django.urls import path
from . import views
urlpatterns = [
path("", views.feed, name="feed"),
path("a/<str:artifact_id>", views.artifact_view, name="artifact"),
path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"),
]

343
artifacts/views.py Normal file
View file

@ -0,0 +1,343 @@
import html
import mimetypes
import secrets
from pathlib import Path
from urllib.parse import urlencode
import markdown
from django.conf import settings
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404, render
from django.template.defaultfilters import slugify
from django.urls import reverse
from django.utils.dateparse import parse_date
from django.utils import timezone
from django.views.decorators.clickjacking import xframe_options_sameorigin
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import Artifact
TEXT_KINDS = {Artifact.HTML_FRAGMENT, Artifact.HTML_FULL, Artifact.MARKDOWN, Artifact.DECK}
DEFAULT_THEMES = {
Artifact.HTML_FRAGMENT: "report",
Artifact.MARKDOWN: "report",
Artifact.DECK: "deck",
}
class ArtifactListCreateView(APIView):
parser_classes = [JSONParser, FormParser, MultiPartParser]
def get(self, request):
artifacts = Artifact.objects.all()
if persona := request.query_params.get("persona"):
artifacts = artifacts.filter(persona=persona)
if day := request.query_params.get("day"):
artifacts = artifacts.filter(day=day)
if tag := request.query_params.get("tag"):
artifacts = artifacts.filter(tags__contains=[tag])
return Response([serialize_artifact(artifact, request) for artifact in artifacts])
def post(self, request):
if not has_valid_token(request):
return HttpResponseForbidden("Invalid or missing API token")
if request.content_type and request.content_type.startswith("multipart/"):
artifact = create_binary_artifact(request.data)
if isinstance(artifact, Response):
return artifact
return Response({"id": artifact.id, "url": reverse("artifact", args=[artifact.id])}, status=201)
data = request.data
kind = data.get("kind")
if kind not in TEXT_KINDS:
return Response({"detail": "kind must be html_fragment, html_full, markdown, or deck"}, status=400)
required = ["persona", "title", "content"]
missing = [field for field in required if not data.get(field)]
if missing:
return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400)
artifact = create_text_artifact(data, request)
return Response({"id": artifact.id, "url": reverse("artifact", args=[artifact.id])}, status=201)
def feed(request):
persona = request.GET.get("persona", "")
selected_kind = request.GET.get("kind", "")
title_query = request.GET.get("q", "")
selected_day = parse_date(request.GET.get("day", "")) or timezone.localdate()
selected_artifact_id = request.GET.get("artifact", "")
selected_view = "raw" if request.GET.get("view") == "raw" else "rendered"
all_artifacts = Artifact.objects.all()
artifacts = all_artifacts
if persona:
artifacts = artifacts.filter(persona=persona)
if selected_kind:
artifacts = artifacts.filter(kind=selected_kind)
if title_query:
artifacts = artifacts.filter(title__icontains=title_query)
days = list(artifacts.dates("day", "day", order="DESC"))
if selected_day not in days:
days.append(selected_day)
days.sort(reverse=True)
day_artifacts = artifacts.filter(day=selected_day).order_by("-created_at")
selected_artifact = day_artifacts.filter(pk=selected_artifact_id).first()
toggle_query = {"day": selected_day.isoformat(), "view": "rendered"}
if persona:
toggle_query["persona"] = persona
if selected_kind:
toggle_query["kind"] = selected_kind
if title_query:
toggle_query["q"] = title_query
if selected_artifact:
toggle_query["artifact"] = selected_artifact.id
rendered_url = f"?{urlencode(toggle_query)}"
toggle_query["view"] = "raw"
raw_url = f"?{urlencode(toggle_query)}"
personas = all_artifacts.order_by("persona").values_list("persona", flat=True).distinct()
return render(
request,
"artifacts/feed.html",
{
"artifacts": day_artifacts,
"days": days,
"personas": personas,
"raw_url": raw_url,
"rendered_url": rendered_url,
"selected_artifact": selected_artifact,
"selected_day": selected_day,
"selected_kind": selected_kind,
"selected_persona": persona,
"selected_view": selected_view,
"title_query": title_query,
"kind_choices": Artifact.KIND_CHOICES,
},
)
@xframe_options_sameorigin
def artifact_view(request, artifact_id):
artifact = get_object_or_404(Artifact, pk=artifact_id)
if request.GET.get("raw") == "1":
raw_path = raw_artifact_path(artifact)
if raw_path and raw_path.exists():
return FileResponse(raw_path.open("rb"), content_type="text/plain; charset=utf-8")
return HttpResponse(raw_unavailable_html(artifact), content_type="text/html")
path = safe_artifact_path(artifact.path)
if not path.exists():
raise Http404("Artifact file not found")
content_type, _ = mimetypes.guess_type(path.name)
return FileResponse(path.open("rb"), content_type=content_type or "application/octet-stream")
def create_text_artifact(data, request):
now = timezone.localtime()
artifact_id = generate_id()
persona = slugify(data["persona"]) or "unknown"
kind = data["kind"]
theme = data.get("theme") or DEFAULT_THEMES.get(kind, "base")
title = data["title"]
content = data["content"]
directory = Path(persona) / now.date().isoformat()
target_dir = settings.ARTIFACTS_ROOT / directory
target_dir.mkdir(parents=True, exist_ok=True)
if kind == Artifact.HTML_FULL:
rendered = content
elif kind == Artifact.MARKDOWN:
rendered = wrap_html(markdown.markdown(content, extensions=["extra", "sane_lists"]), title, theme, request)
(target_dir / f"{artifact_id}.md").write_text(content, encoding="utf-8")
elif kind == Artifact.DECK:
rendered = wrap_deck(content, title, request)
else:
rendered = wrap_html(content, title, theme, request)
stored_path = directory / f"{artifact_id}.html"
(settings.ARTIFACTS_ROOT / stored_path).write_text(rendered, encoding="utf-8")
return Artifact.objects.create(
id=artifact_id,
persona=persona,
title=title,
kind=kind,
theme=theme,
day=now.date(),
path=stored_path.as_posix(),
original_kind=kind,
tags=normalize_tags(data.get("tags", [])),
source_prompt=data.get("source_prompt", ""),
)
def create_binary_artifact(data):
uploaded = data.get("file")
required = ["persona", "title"]
missing = [field for field in required if not data.get(field)]
if not uploaded:
missing.append("file")
if missing:
return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400)
now = timezone.localtime()
artifact_id = generate_id()
persona = slugify(data["persona"]) or "unknown"
title = data["title"]
content_type = getattr(uploaded, "content_type", "") or mimetypes.guess_type(uploaded.name)[0] or ""
kind = binary_kind(content_type)
directory = Path(persona) / now.date().isoformat()
target_dir = settings.ARTIFACTS_ROOT / directory
target_dir.mkdir(parents=True, exist_ok=True)
suffix = safe_suffix(uploaded.name, content_type)
stored_path = directory / f"{artifact_id}{suffix}"
with (settings.ARTIFACTS_ROOT / stored_path).open("wb") as target:
for chunk in uploaded.chunks():
target.write(chunk)
return Artifact.objects.create(
id=artifact_id,
persona=persona,
title=title,
kind=kind,
theme="",
day=now.date(),
path=stored_path.as_posix(),
original_kind=kind,
tags=normalize_tags(data.get("tags", [])),
source_prompt=data.get("source_prompt", ""),
)
def wrap_html(body, title, theme, request):
stylesheet = request.build_absolute_uri(f"/static/themes/{theme}.css")
return f"""<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(title)}</title>
<link rel="stylesheet" href="{stylesheet}">
</head>
<body class="artifact">
{body}
</body>
</html>
"""
def wrap_deck(body, title, request):
stylesheet = request.build_absolute_uri("/static/themes/deck.css")
return f"""<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(title)}</title>
<link rel="stylesheet" href="{stylesheet}">
</head>
<body class="deck">
<main class="slides">{body}</main>
</body>
</html>
"""
def generate_id():
while True:
artifact_id = secrets.token_hex(4)
if not Artifact.objects.filter(pk=artifact_id).exists():
return artifact_id
def has_valid_token(request):
expected = getattr(settings, "DAYBOOK_API_TOKEN", "")
auth = request.headers.get("Authorization", "")
return bool(expected) and auth == f"Bearer {expected}"
def normalize_tags(tags):
if isinstance(tags, str):
return [tag.strip() for tag in tags.split(",") if tag.strip()]
if isinstance(tags, list):
return [str(tag).strip() for tag in tags if str(tag).strip()]
return []
def binary_kind(content_type):
if content_type.startswith("image/"):
return Artifact.IMAGE
if content_type == "application/pdf":
return Artifact.PDF
return Artifact.BINARY
def safe_suffix(filename, content_type):
suffix = Path(filename).suffix.lower()
if suffix and suffix[1:].isalnum() and len(suffix) <= 10:
return suffix
return mimetypes.guess_extension(content_type) or ".bin"
def serialize_artifact(artifact, request):
return {
"id": artifact.id,
"url": request.build_absolute_uri(reverse("artifact", args=[artifact.id])),
"persona": artifact.persona,
"title": artifact.title,
"kind": artifact.kind,
"theme": artifact.theme,
"day": artifact.day.isoformat(),
"created_at": artifact.created_at.isoformat(),
"tags": artifact.tags,
"source_prompt": artifact.source_prompt,
}
def safe_artifact_path(relative_path):
root = settings.ARTIFACTS_ROOT.resolve()
path = (root / relative_path).resolve()
if root not in path.parents and path != root:
raise Http404("Invalid artifact path")
return path
def raw_artifact_path(artifact):
path = safe_artifact_path(artifact.path)
if artifact.kind == Artifact.MARKDOWN:
return path.with_suffix(".md")
if artifact.kind == Artifact.HTML_FULL:
return path
return None
def raw_unavailable_html(artifact):
return f"""<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Raw source unavailable</title>
<style>
body {{ background: #f4f1ea; color: #1d2528; font-family: system-ui, sans-serif; margin: 0; padding: 2rem; }}
main {{ background: #fffaf0; border-radius: 16px; max-width: 42rem; padding: 1.4rem; }}
</style>
</head>
<body>
<main>
<h1>Raw source unavailable</h1>
<p>Daybook does not have preserved raw source for <strong>{html.escape(artifact.title)}</strong>.</p>
</main>
</body>
</html>
"""

View file

8
daybook_project/asgi.py Normal file
View file

@ -0,0 +1,8 @@
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "daybook_project.settings")
application = get_asgi_application()

View file

@ -0,0 +1,70 @@
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = "dev-only-change-me"
DEBUG = True
ALLOWED_HOSTS = ["*"]
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"artifacts",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "daybook_project.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "daybook_project.wsgi.application"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
AUTH_PASSWORD_VALIDATORS = []
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
STATICFILES_DIRS = [BASE_DIR / "static"]
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
ARTIFACTS_ROOT = BASE_DIR / "artifacts_store"
DAYBOOK_API_TOKEN = "dev-token"

8
daybook_project/urls.py Normal file
View file

@ -0,0 +1,8 @@
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("", include("artifacts.urls")),
]

8
daybook_project/wsgi.py Normal file
View file

@ -0,0 +1,8 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "daybook_project.settings")
application = get_wsgi_application()

14
manage.py Normal file
View file

@ -0,0 +1,14 @@
#!/usr/bin/env python
import os
import sys
def main():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "daybook_project.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

5
opencode.json Normal file
View file

@ -0,0 +1,5 @@
{
"$schema": "https://opencode.ai/config.json",
"instructions": ["AGENTS.md"],
"permission": "allow"
}

View file

@ -0,0 +1,216 @@
# Daybook — Project Planning
## Purpose
**Daybook** is a self-hosted web application where AI agents publish their
outputs (task lists, research reports, slide decks, images, etc.) and a human
browses them in a **time-ordered, agent-filterable feed** — viewing everything
**in-browser without downloading or "opening" files**.
The defining feature is the integration: an agent calls a REST API and posts
*content*, not files. For HTML it can send just a body fragment and Daybook
supplies the CSS. The agent never deals with styling, file naming, or storage.
## Core principles
- **View, don't download.** Everything renders inline in the browser.
- **Agent-agnostic.** Any agent/persona can publish; the human filters by persona.
- **Time-ordered.** The primary axis is the day; flipping back to yesterday is trivial.
- **Minimal agent burden.** Agents send content + a few metadata fields. The server
owns styling, storage layout, IDs, and dates.
- **Self-hosted.** A single deployable unit running on the user's own machine/server.
- **Dumb viewer, smart ingest.** Normalize every input to three browser-native forms
(HTML, image, PDF) at upload time, so the viewer never needs plugins.
## Actors
- **Agent (publisher):** an authenticated machine client (e.g. Claude Code, a
persona script). POSTs artifacts via REST.
- **Human (reader):** browses, filters, and views artifacts; later edits markdown
and shares links.
## Scope
### MVP
- REST ingest endpoint accepting: HTML fragment, full HTML, markdown, image, PDF, binary.
- Server-side **named CSS themes**; automatic wrapping of HTML fragments.
- Storage in a `persona/day/id` layout plus a metadata table.
- Web viewer: reverse-chronological feed, persona filter, in-browser rendering.
### Later
- In-browser **markdown editing** with write-back (the name "Daybook" anticipates this).
- Slide decks via a **reveal.js** theme; `pptx`/`docx`/`xlsx` → PDF fallback.
- **Share links** (password + expiry) for individual artifacts.
- Tags, search, and full-text indexing.
## Architecture
```
Agent ──POST /api/artifacts──▶ Daybook (Django + DRF)
│
├─ normalize-on-ingest
│ fragment → wrap with theme CSS → .html
│ markdown → render → .html
│ deck → reveal.js scaffold → .html
│ office → LibreOffice headless → .pdf
│ image/pdf → store as-is
│
├─ write file to artifacts/{persona}/{day}/{id}.{ext}
└─ insert Artifact row (metadata index)
Human ──GET / (feed)──▶ list Artifact rows (reverse-chron, persona filter)
──GET /a/{id}──▶ render via <iframe> (html/pdf) or <img> (image)
```
The viewer only ever handles **HTML, image, or PDF**, because everything is
collapsed into those three at ingest. No client-side office/pptx rendering.
## Data model
`Artifact`
| Field | Type | Notes |
|---|---|---|
| `id` | str (8-char) | short unique id, used in URLs (`/a/{id}`) |
| `persona` | str (indexed) | the agent that published it; primary filter |
| `title` | str | display title |
| `kind` | str | `html_fragment` \| `html_full` \| `markdown` \| `deck` \| `image` \| `pdf` \| `binary` |
| `theme` | str | named CSS theme used at render (nullable) |
| `day` | date (indexed) | publication day; feed grouping key |
| `created_at` | datetime | exact timestamp |
| `path` | str | relative path to stored, normalized file |
| `original_kind` | str | what the agent sent, pre-normalization (audit) |
| `tags` | json/list | later: filtering/search |
| `source_prompt` | text | optional: the prompt that produced it |
Share fields (`share_token`, `share_expires`, `share_password_hash`) added in the
share-links milestone, not MVP.
## REST API (MVP contract)
All ingest endpoints require an API token (per-persona; see Open Questions).
### `POST /api/artifacts` (JSON — text content)
```json
{
"persona": "hermes",
"kind": "html_fragment", // or html_full | markdown | deck
"theme": "report", // optional; defaults per kind
"title": "Daily Tasks 2026-06-10",
"content": "<h1>Today</h1><ul><li>...</li></ul>",
"tags": ["tasks"], // optional
"source_prompt": "..." // optional
}
```
Response: `{ "id": "a1b2c3d4", "url": "/a/a1b2c3d4" }`
### `POST /api/artifacts` (multipart — binary)
Fields: `persona`, `title`, `tags`; file part `file` (image / pdf / office).
Office/deck files are converted to PDF on ingest.
### `GET /api/artifacts?persona=&day=&tag=`
Returns metadata list (reverse-chronological). Drives the feed.
### `GET /a/{id}`
Serves the rendered artifact (HTML/PDF/image) for inline viewing.
## Theming (server-side CSS)
The server owns styling so agents can send markup only. Themes live as static
files and are selected by name in the POST body:
```
static/themes/report.css
static/themes/tasks.css
static/themes/deck.css # reveal.js theme
static/themes/base.css # default
```
Fragment wrapping (conceptual):
```python
WRAP = """<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="/static/themes/{theme}.css">
<title>{title}</title></head>
<body class="artifact">{body}</body></html>"""
```
- `html_fragment` → wrapped with the chosen theme.
- `markdown` → rendered to HTML, then wrapped.
- `deck` → wrapped in reveal.js scaffold (agent sends `<section>` blocks).
- `html_full` → stored verbatim (agent owns everything).
## Ingest normalization pipeline
| Agent sends | Step | Stored as | Viewer |
|---|---|---|---|
| HTML fragment | wrap + theme | `.html` | iframe |
| Full HTML | verbatim | `.html` | iframe |
| Markdown | render + wrap | `.html` | iframe |
| Deck (sections) | reveal.js wrap | `.html` | iframe |
| Image | verbatim | image | `<img>` |
| PDF | verbatim | `.pdf` | native / pdf.js |
| pptx/docx/xlsx | LibreOffice → PDF | `.pdf` | native / pdf.js |
## Viewer
- **Feed view (`/`):** reverse-chronological list grouped by day, with a persona
filter (dropdown or sidebar) and, later, tag/search. Each entry shows title,
persona, time, and kind.
- **Artifact view (`/a/{id}`):** renders inline — `<iframe>` for HTML/PDF,
`<img>` for images. No download step required (download remains available).
- Markdown edit (later): the stored source markdown is editable in-browser and
re-rendered on save; keep the original `.md` alongside the rendered `.html`.
## Storage layout
```
artifacts/
{persona}/
{YYYY-MM-DD}/
{id}.html | {id}.pdf | {id}.png ...
{id}.md # kept when source is markdown (for editing)
```
Files on disk stay greppable, portable, and trivially backed up; the DB is an
index over them, not the source of truth for content.
## Tech stack
- **Django + Django REST Framework** — API, metadata, viewer.
- **SQLite** (dev) → **PostgreSQL** (later) for the metadata index.
- **Files on disk** for artifact content.
- **LibreOffice headless** in the container for office/deck → PDF.
- **markdown** (python-markdown) for md rendering; **reveal.js** for decks.
- **pdf.js** optional, only if the native PDF viewer is unsatisfactory.
- **Docker** — single image, self-hosted.
## Milestones
1. **M1 — Core ingest + storage.** `Artifact` model, JSON POST for
`html_fragment`/`html_full`/`markdown`, theme wrapping, disk write, `/a/{id}` view.
2. **M2 — Viewer.** Feed (`/`) with reverse-chron listing and persona filter;
inline iframe/img rendering.
3. **M3 — Binary + conversion.** Multipart upload; image/PDF passthrough;
LibreOffice headless for office/deck → PDF.
4. **M4 — Decks.** reveal.js theme + `deck` kind.
5. **M5 — Editing.** In-browser markdown edit + re-render.
6. **M6 — Sharing.** Share tokens with password + expiry.
7. **M7 — Search/tags.** Tag filter and full-text search.
## Future features
- Per-persona dashboards / stats.
- Webhooks or "latest artifact" endpoints for chaining agents.
- Retention/archival policies per persona.
- Multi-user reader auth with per-persona visibility.
## Open questions / decisions
- **Output standardization:** push agents toward HTML/markdown/reveal.js
(cheap, robust) vs. accept native office files (LibreOffice conversion is the
heaviest, flakiest path). Recommendation: prefer the former, keep conversion
as a fallback.
- **Agent auth:** one API token per persona, or a shared token + persona field?
- **Reader auth:** single-user (simplest) vs. multi-user from the start?
- **ID scheme:** random short id vs. date-prefixed sortable id.

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
Django>=5.0,<6.0
djangorestframework>=3.15,<4.0
Markdown>=3.6,<4.0

411
static/daybook.css Normal file
View file

@ -0,0 +1,411 @@
:root {
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f4f1ea;
color: #1d2528;
}
body {
margin: 0;
}
.topbar {
align-items: center;
background: #17313b;
color: #fffaf0;
display: grid;
gap: 0;
grid-template-columns: minmax(13rem, 25%) 1fr;
padding: 0;
}
.brandline {
align-items: baseline;
display: flex;
gap: 0.55rem;
padding: 0.38rem 0.65rem;
}
.brandline p {
color: #d9e5e8;
font-size: 0.78rem;
font-weight: 600;
margin: 0;
}
.eyebrow {
color: #e2b66a;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.14em;
margin: 0 0 0.18rem;
text-transform: uppercase;
}
h1 {
font-size: clamp(1.2rem, 2vw, 1.75rem);
line-height: 1;
margin: 0;
}
.toolbar {
align-items: center;
align-self: stretch;
background: #d9c9ad;
display: flex;
gap: 0.45rem;
min-width: 0;
padding: 0.32rem 0.6rem;
}
.filter {
align-items: end;
display: flex;
gap: 0.35rem;
}
.filter label {
color: #304149;
display: grid;
font-size: 0.68rem;
gap: 0.08rem;
}
.filter select,
.filter input {
border: 0;
border-radius: 999px;
font: inherit;
padding: 0.32rem 0.62rem;
}
.filter input {
width: 9rem;
}
.filter button {
background: #17313b;
border: 0;
border-radius: 999px;
color: #fffaf0;
cursor: pointer;
font: inherit;
font-weight: 700;
padding: 0.32rem 0.7rem;
}
.view-toggle {
background: rgba(23, 49, 59, 0.12);
border: 1px solid rgba(23, 49, 59, 0.18);
border-radius: 999px;
display: flex;
padding: 0.12rem;
}
.view-toggle a {
border-radius: 999px;
color: #17313b;
font-size: 0.76rem;
padding: 0.3rem 0.58rem;
text-decoration: none;
}
.view-toggle a.selected {
background: #17313b;
color: #fffaf0;
}
.feed {
--artifacts-width: 280px;
--days-width: 144px;
display: grid;
grid-template-columns: var(--days-width) 1px var(--artifacts-width) 1px minmax(0, 1fr);
min-height: calc(100vh - 3.2rem);
}
.day-sidebar,
.artifact-sidebar,
.preview-pane {
min-width: 0;
padding: 0.55rem;
}
.day-sidebar {
background: #ebe4d5;
}
.artifact-sidebar {
background: #f8f2e7;
}
.preview-pane {
align-items: stretch;
display: flex;
flex-direction: column;
justify-content: center;
padding: 0;
}
.splitter {
background: #d9c9ad;
cursor: col-resize;
position: relative;
touch-action: none;
}
.splitter::after {
background: transparent;
bottom: 0;
content: "";
left: -4px;
position: absolute;
right: -4px;
top: 0;
}
.splitter:hover,
.is-resizing .splitter {
background: #a98d5e;
}
.is-resizing {
cursor: col-resize;
user-select: none;
}
.day-sidebar h2,
.pane-heading h2 {
color: #68543b;
font-size: 0.68rem;
letter-spacing: 0.1em;
margin: 0 0 0.45rem;
text-transform: uppercase;
}
.day-list,
.artifact-list {
display: grid;
gap: 0.35rem;
}
.day-link,
.artifact-row {
color: inherit;
text-decoration: none;
}
.day-link {
border-radius: 10px;
display: grid;
gap: 0.1rem;
padding: 0.48rem;
}
.day-link:hover,
.day-link.selected {
background: #fffaf0;
box-shadow: 0 10px 24px rgba(57, 44, 28, 0.08);
}
.day-link span,
.pane-heading span,
.artifact-row-main strong {
color: #7b6b55;
font-size: 0.7rem;
}
.day-link strong {
color: #17313b;
font-size: 0.82rem;
}
.pane-heading {
align-items: baseline;
display: flex;
justify-content: space-between;
margin-bottom: 0.45rem;
}
.artifact-list {
align-content: start;
}
.artifact-row {
align-items: center;
background: #fffaf0;
border: 1px solid #e5d8c0;
border-radius: 11px;
display: flex;
gap: 0.45rem;
padding: 0.45rem;
}
.artifact-row:hover {
border-color: #c9b38d;
box-shadow: 0 12px 28px rgba(57, 44, 28, 0.08);
}
.artifact-row.selected {
background: #17313b;
border-color: #17313b;
color: #fffaf0;
}
.artifact-row.selected .artifact-row-main strong,
.artifact-row.selected .artifact-row-main span {
color: #fffaf0;
}
.kind-icon {
align-items: center;
background: #dfe9ea;
border-radius: 7px;
color: #17313b;
display: inline-flex;
flex: 0 0 2.25rem;
font-size: 0.58rem;
font-weight: 800;
height: 1.75rem;
justify-content: center;
letter-spacing: 0.04em;
}
.kind-image {
background: #dfeee2;
color: #265336;
}
.kind-pdf {
background: #f3dedb;
color: #78352f;
}
.kind-markdown {
background: #e7e1f2;
color: #443265;
}
.kind-deck {
background: #f6e5bf;
color: #6e4a12;
}
.artifact-row-main {
display: grid;
gap: 0.1rem;
min-width: 0;
}
.artifact-row-main span {
color: #17313b;
font-size: 0.86rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty {
background: #fffaf0;
border-radius: 16px;
padding: 1.2rem;
}
.empty.compact {
padding: 1rem;
}
.empty.compact h3,
.preview-empty h2 {
margin-top: 0;
}
.preview-empty {
max-width: 34rem;
}
.preview-frame-wrap {
align-items: center;
display: flex;
flex: 1;
justify-content: center;
min-height: 0;
}
.artifact-actions {
align-items: center;
background: #d9c9ad;
border-top: 1px solid #c7b48f;
display: flex;
flex: 0 0 auto;
gap: 0.75rem;
justify-content: space-between;
min-height: 2.25rem;
padding: 0.28rem 0.55rem;
}
.artifact-action-links {
align-items: center;
display: flex;
gap: 0.35rem;
}
.artifact-action-links a,
.artifact-action-links button {
background: #fffaf0;
border: 1px solid rgba(23, 49, 59, 0.14);
border-radius: 999px;
color: #17313b;
cursor: pointer;
font: inherit;
font-size: 0.76rem;
line-height: 1;
padding: 0.42rem 0.65rem;
text-decoration: none;
}
.artifact-action-links button:disabled {
cursor: not-allowed;
opacity: 0.48;
}
.preview-frame {
background: #fff;
border: 0;
height: 100%;
width: 100%;
}
.preview-image {
background: #fff;
max-height: 100%;
max-width: 100%;
object-fit: contain;
}
@media (max-width: 720px) {
.topbar {
align-items: stretch;
grid-template-columns: 1fr;
}
.toolbar,
.filter,
.artifact-actions {
align-items: stretch;
flex-wrap: wrap;
}
.feed {
grid-template-columns: 1fr;
}
.splitter {
display: none;
}
.day-sidebar,
.artifact-sidebar {
border-bottom: 1px solid #d9c9ad;
border-right: 0;
}
}

18
static/themes/base.css Normal file
View file

@ -0,0 +1,18 @@
body.artifact {
box-sizing: border-box;
color: #1d2528;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.65;
margin: 0 auto;
max-width: 900px;
padding: 3rem 1.25rem;
}
img,
table {
max-width: 100%;
}
pre {
overflow-x: auto;
}

30
static/themes/deck.css Normal file
View file

@ -0,0 +1,30 @@
body.deck {
background: #111827;
color: #f9fafb;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
margin: 0;
}
.slides {
display: grid;
gap: 2rem;
margin: 0 auto;
max-width: 1100px;
padding: 2rem;
}
.slides section {
background: linear-gradient(135deg, #1f2937, #111827);
border: 1px solid #374151;
border-radius: 28px;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
min-height: 62vh;
padding: clamp(2rem, 6vw, 5rem);
}
.slides h1,
.slides h2 {
color: #fbbf24;
font-size: clamp(2rem, 7vw, 5rem);
line-height: 1;
}

41
static/themes/report.css Normal file
View file

@ -0,0 +1,41 @@
@import url("/static/themes/base.css");
body.artifact {
background: #fffaf0;
color: #1e2b2f;
font-family: Georgia, "Times New Roman", serif;
font-size: 1.08rem;
}
h1,
h2,
h3 {
color: #17313b;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.15;
}
a {
color: #8a4f16;
}
blockquote {
border-left: 4px solid #d5a24c;
color: #5b5449;
margin-left: 0;
padding-left: 1rem;
}
code,
pre {
background: #efe5d2;
border-radius: 8px;
}
code {
padding: 0.1rem 0.3rem;
}
pre {
padding: 1rem;
}

20
static/themes/tasks.css Normal file
View file

@ -0,0 +1,20 @@
@import url("/static/themes/base.css");
body.artifact {
background: #f7fbff;
color: #17202a;
max-width: 760px;
}
h1 {
border-bottom: 3px solid #66a7d8;
padding-bottom: 0.5rem;
}
li {
margin: 0.45rem 0;
}
input[type="checkbox"] {
transform: translateY(1px) scale(1.1);
}

View file

@ -0,0 +1,275 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Daybook</title>
<link rel="stylesheet" href="/static/daybook.css">
</head>
<body>
<header class="topbar">
<div class="brandline">
<h1>Daybook</h1>
<p>Agent Artifacts</p>
</div>
<div class="toolbar">
<form method="get" class="filter">
<input type="hidden" name="day" value="{{ selected_day|date:'Y-m-d' }}">
<input type="hidden" name="view" value="{{ selected_view }}">
{% if selected_artifact %}<input type="hidden" name="artifact" value="{{ selected_artifact.id }}">{% endif %}
<label>
<span>Persona</span>
<select name="persona" onchange="this.form.submit()">
<option value="">All personas</option>
{% for persona in personas %}
<option value="{{ persona }}" {% if persona == selected_persona %}selected{% endif %}>{{ persona }}</option>
{% endfor %}
</select>
</label>
<label>
<span>Type</span>
<select name="kind" onchange="this.form.submit()">
<option value="">All types</option>
{% for value, label in kind_choices %}
<option value="{{ value }}" {% if value == selected_kind %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>
<span>Name</span>
<input type="search" name="q" value="{{ title_query }}" placeholder="Title contains" onsearch="this.form.submit()">
</label>
<button type="submit">Filter</button>
</form>
</div>
</header>
<main class="feed">
<aside class="day-sidebar" aria-label="Days">
<h2>Days</h2>
<nav class="day-list">
{% for day in days %}
<a class="day-link {% if day == selected_day %}selected{% endif %}" href="?day={{ day|date:'Y-m-d' }}{% if selected_persona %}&amp;persona={{ selected_persona|urlencode }}{% endif %}{% if selected_kind %}&amp;kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&amp;q={{ title_query|urlencode }}{% endif %}&amp;view={{ selected_view }}">
<span>{{ day|date:"D" }}</span>
<strong>{{ day|date:"Y-m-d" }}</strong>
</a>
{% endfor %}
</nav>
</aside>
<div class="splitter" data-splitter="days" role="separator" aria-label="Resize days sidebar" aria-orientation="vertical"></div>
<aside class="artifact-sidebar" aria-label="Artifacts for selected day">
<div class="pane-heading">
<h2>{{ selected_day|date:"Y-m-d" }}</h2>
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
</div>
<div class="artifact-list">
{% for artifact in artifacts %}
<a class="artifact-row {% if selected_artifact.id == artifact.id %}selected{% endif %}"
href="?day={{ selected_day|date:'Y-m-d' }}{% if selected_persona %}&amp;persona={{ selected_persona|urlencode }}{% endif %}{% if selected_kind %}&amp;kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&amp;q={{ title_query|urlencode }}{% endif %}&amp;artifact={{ artifact.id }}&amp;view={{ selected_view }}"
data-artifact-id="{{ artifact.id }}"
data-title="{{ artifact.title }}"
data-kind="{{ artifact.kind }}"
data-rendered-url="{% url 'artifact' artifact.id %}"
data-raw-url="{% url 'artifact' artifact.id %}?raw=1">
<span class="kind-icon kind-{{ artifact.kind }}" aria-hidden="true">
{% if artifact.kind == "image" %}IMG{% elif artifact.kind == "pdf" %}PDF{% elif artifact.kind == "markdown" %}MD{% elif artifact.kind == "deck" %}DECK{% elif artifact.kind == "binary" %}BIN{% else %}HTML{% endif %}
</span>
<span class="artifact-row-main">
<strong>{{ artifact.created_at|date:"H:i" }}</strong>
<span>{{ artifact.title }}</span>
</span>
</a>
{% empty %}
<div class="empty compact">
<h3>No artifacts</h3>
<p>This day is ready for new agent output.</p>
</div>
{% endfor %}
</div>
</aside>
<div class="splitter" data-splitter="artifacts" role="separator" aria-label="Resize artifacts sidebar" aria-orientation="vertical"></div>
<section class="preview-pane">
{% if selected_artifact %}
<div class="preview-frame-wrap">
{% if selected_view == "raw" %}
<iframe class="preview-frame" src="{% url 'artifact' selected_artifact.id %}?raw=1" title="{{ selected_artifact.title }} raw source"></iframe>
{% elif selected_artifact.kind == "image" %}
<img class="preview-image" src="{% url 'artifact' selected_artifact.id %}" alt="{{ selected_artifact.title }}">
{% elif selected_artifact.kind == "binary" %}
<div class="empty preview-empty">
<h2>Binary Artifact</h2>
<p>This artifact type cannot be previewed inline yet.</p>
<p><a href="{% url 'artifact' selected_artifact.id %}">Open or download it</a>.</p>
</div>
{% else %}
<iframe class="preview-frame" src="{% url 'artifact' selected_artifact.id %}{% if selected_view == 'raw' %}?raw=1{% endif %}" title="{{ selected_artifact.title }}"></iframe>
{% endif %}
</div>
<footer class="artifact-actions" data-actions>
<nav class="view-toggle" aria-label="Preview mode">
<a class="{% if selected_view == 'rendered' %}selected{% endif %}" href="{{ rendered_url }}" data-view="rendered">Rendered</a>
<a class="{% if selected_view == 'raw' %}selected{% endif %}" href="{{ raw_url }}" data-view="raw">Raw</a>
</nav>
<div class="artifact-action-links">
<a href="{% url 'artifact' selected_artifact.id %}" target="_blank" rel="noopener">Open</a>
<a href="{% url 'artifact' selected_artifact.id %}" download>Download</a>
<button type="button" data-copy-url="{% url 'artifact' selected_artifact.id %}">Copy Link</button>
<button type="button" disabled>Send</button>
</div>
</footer>
{% else %}
<div class="empty preview-empty">
<h2>Select an artifact</h2>
<p>Choose an item from the middle column to preview it here.</p>
<p>POST to <code>/api/artifacts</code> with <code>Authorization: Bearer dev-token</code>.</p>
</div>
{% endif %}
</section>
</main>
<script>
(() => {
const feed = document.querySelector(".feed");
if (!feed) return;
let selectedView = "{{ selected_view }}";
const sizes = {
days: Number(localStorage.getItem("daybook.daysWidth")) || 144,
artifacts: Number(localStorage.getItem("daybook.artifactsWidth")) || 280,
};
const applySizes = () => {
feed.style.setProperty("--days-width", `${sizes.days}px`);
feed.style.setProperty("--artifacts-width", `${sizes.artifacts}px`);
};
applySizes();
document.querySelectorAll(".splitter").forEach((splitter) => {
splitter.addEventListener("pointerdown", (event) => {
const target = splitter.dataset.splitter;
const startX = event.clientX;
const startWidth = sizes[target];
splitter.setPointerCapture(event.pointerId);
document.body.classList.add("is-resizing");
const move = (moveEvent) => {
sizes[target] = Math.max(96, Math.min(420, startWidth + moveEvent.clientX - startX));
applySizes();
};
const up = () => {
localStorage.setItem(`daybook.${target}Width`, String(sizes[target]));
document.body.classList.remove("is-resizing");
splitter.removeEventListener("pointermove", move);
splitter.removeEventListener("pointerup", up);
splitter.removeEventListener("pointercancel", up);
};
splitter.addEventListener("pointermove", move);
splitter.addEventListener("pointerup", up);
splitter.addEventListener("pointercancel", up);
});
});
const previewPane = document.querySelector(".preview-pane");
const rows = Array.from(document.querySelectorAll(".artifact-row"));
const escapeHtml = (value) => value.replace(/[&<>"]/g, (character) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
}[character]));
const copyLink = async (button) => {
const absoluteUrl = new URL(button.dataset.copyUrl, window.location.origin).toString();
try {
await navigator.clipboard.writeText(absoluteUrl);
} catch {
window.prompt("Copy artifact link", absoluteUrl);
}
const original = button.textContent;
button.textContent = "Copied";
setTimeout(() => {
button.textContent = original;
}, 1200);
};
const selectedRow = () => document.querySelector(".artifact-row.selected");
const urlFor = (row, view) => view === "raw" ? row.dataset.rawUrl : row.dataset.renderedUrl;
const pageUrlFor = (row, view) => {
const url = new URL(row.href, window.location.origin);
url.searchParams.set("artifact", row.dataset.artifactId);
url.searchParams.set("view", view);
return url;
};
const updateActions = (row) => {
const actions = document.querySelector("[data-actions]");
if (!actions) return;
actions.querySelectorAll("[data-view]").forEach((link) => {
const view = link.dataset.view;
link.classList.toggle("selected", view === selectedView);
link.href = pageUrlFor(row, view).toString();
});
actions.querySelectorAll(".artifact-action-links a").forEach((link) => {
link.href = row.dataset.renderedUrl;
});
const copyButton = actions.querySelector("[data-copy-url]");
if (copyButton) copyButton.dataset.copyUrl = row.dataset.renderedUrl;
};
const renderPreview = (row, pushState = true) => {
rows.forEach((candidate) => candidate.classList.toggle("selected", candidate === row));
const source = urlFor(row, selectedView);
const title = escapeHtml(row.dataset.title);
const kind = row.dataset.kind;
const content = selectedView === "raw"
? `<iframe class="preview-frame" src="${source}" title="${title} raw source"></iframe>`
: kind === "image"
? `<img class="preview-image" src="${source}" alt="${title}">`
: kind === "binary"
? `<div class="empty preview-empty"><h2>Binary Artifact</h2><p>This artifact type cannot be previewed inline yet.</p><p><a href="${source}">Open or download it</a>.</p></div>`
: `<iframe class="preview-frame" src="${source}" title="${title}"></iframe>`;
let frameWrap = previewPane.querySelector(".preview-frame-wrap");
if (!frameWrap) {
previewPane.innerHTML = `<div class="preview-frame-wrap"></div><footer class="artifact-actions" data-actions><nav class="view-toggle" aria-label="Preview mode"><a data-view="rendered">Rendered</a><a data-view="raw">Raw</a></nav><div class="artifact-action-links"><a target="_blank" rel="noopener">Open</a><a download>Download</a><button type="button" data-copy-url="">Copy Link</button><button type="button" disabled>Send</button></div></footer>`;
frameWrap = previewPane.querySelector(".preview-frame-wrap");
}
frameWrap.innerHTML = content;
updateActions(row);
if (pushState) window.history.pushState({}, "", pageUrlFor(row, selectedView));
};
rows.forEach((row) => {
row.addEventListener("click", (event) => {
event.preventDefault();
renderPreview(row);
});
});
previewPane.addEventListener("click", (event) => {
const copyButton = event.target.closest("[data-copy-url]");
if (copyButton) {
event.preventDefault();
copyLink(copyButton);
return;
}
const toggle = event.target.closest("[data-view]");
if (!toggle) return;
const row = selectedRow();
if (!row) return;
event.preventDefault();
selectedView = toggle.dataset.view;
renderPreview(row);
});
})();
</script>
</body>
</html>