Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Secrets — never commit
.env

# Portal mode's local sqlite fallback store (created by `python -m fireflyer.portal`
# when no DATABASE_URL is set)
portal.db

# Python
.venv/
__pycache__/
Expand Down
59 changes: 58 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.4.0] - 2026-07-10

### Added

- **Portal mode** — an opt-in, DB-backed way to store and browse many
dashboards, reusing the existing editor unchanged. Enabled with
`python -m fireflyer.portal` (reads `portal.yaml`) or the compose `portal`
profile. `/` becomes a gallery of stored dashboards — a table of name,
author, and last-updated with per-row **Edit / Clone / Remove** actions and a
**+ New dashboard** button; New and Clone each prompt for a name in a modal.
New dashboards start **blank**; each opens in the normal editor with a
**Save** button. Dashboards are stored as an opaque YAML text blob (validated
by `Dashboard.from_yaml` on save, never decomposed into tables), so every
stateless editor route keeps working byte-for-byte. Rows also carry an
**author** (the logged-in user, recorded at create/clone).
- **Portal login** — portal mode is gated behind a simple auth (`web/auth.py`),
default **admin/admin** (`FIREFLYER_USER`/`FIREFLYER_PASSWORD`); a topbar
**profile** dropdown shows the username with a **Log out** action. It's a deliberately small,
swappable backbone: an `Authenticator` protocol (the credential check) and an
HMAC-signed session cookie (how the identity is remembered) are independent,
so an SSO/OAuth callback just reuses `set_session` — no route changes. No
advanced provider is implemented; the extension recipe is documented in
`architecture.md`. Local single-dashboard mode has no login.
- **Editor topbar** reorganized — left: a **☰ Dashboards** link and the
Fireflyer **logo** (both link to the gallery in portal), then an **editable
dashboard title** after a dot separator (click to rename → rewrites the YAML
`name:` key, and editing `name:` in the YAML updates the title); right:
**Save**, Preview,
a **3-segment Auto / Light / Dark theme switch**, and the profile button.
**Save only appears when there are unsaved changes**, saves on click or
⌘/Ctrl+S, and warns before you navigate away with unsaved edits. The theme
control is a **3-segment icon switch** (A / sun / moon for Auto / Light /
Dark, inline SVG, no text) — in the profile
dropdown in portal mode, standalone in the topbar in local mode.
- **Refresh-on-edit preview.** The topbar **Run** button and status text are
gone. Editing the YAML now greys out the (stale) preview and reveals a **↻
Refresh** button over the output pane; clicking it re-renders. The greyed
preview stays **interactive** (row/column resize keeps working). Two resize
snap-back bugs were fixed: row-height drags now persist for **block-style**
dashboard rows (the height rewrite was flow-style only), and **column** drags
now persist on **tabbed** dashboards (`resize_columns` searched flat `.items`
only, which are empty when the layout is tabbed, so it silently no-op'd). Save
feedback shows on the Save button itself, and rare edit errors use a toast.
- **Required top-level `name:` key** in the dashboard YAML — the dashboard's
display name, part of the definition (not portal metadata), so it works the
same in local and portal mode. `Dashboard.from_yaml` now rejects a dashboard
with no (or empty) `name`. Portal lists dashboards by it and re-derives the
listing name from the YAML on every save (no separate name field); the
gallery's "new" form seeds the typed name into the YAML's `name:` key. The store lives in `fireflyer/web/portal.py` behind two
backends: stdlib **sqlite** (local/dev + tests) and **Postgres**
(`python -m fireflyer.portal`); the Postgres driver is an optional `.[portal]`
extra so the core install and test suite never require a database. Portal is
an owner-approved exception to the "no persistence/multi-user" anti-goal,
scoped to `web/`. Auth and per-dataset storage are intentionally out of scope
for this first cut.

## [0.3.1] - 2026-07-09

### Added
Expand Down Expand Up @@ -85,7 +141,8 @@ production-ready.
definition with the exact expected HTML in `tests/snapshots/`.
- **Source-available license.** Apache-2.0 with the Commons Clause.

[Unreleased]: https://github.com/dankor/fireflyer/compare/v0.3.1...HEAD
[Unreleased]: https://github.com/dankor/fireflyer/compare/v0.4.0...HEAD
[0.4.0]: https://github.com/dankor/fireflyer/compare/v0.3.1...v0.4.0
[0.3.1]: https://github.com/dankor/fireflyer/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/dankor/fireflyer/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/dankor/fireflyer/compare/v0.1.0...v0.2.0
Expand Down
13 changes: 12 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,24 @@ The editor renders a hover **toolbar** on every chart (edit + delete buttons, on
- **Adding to the layout**: the editor's left gutter shows hover **"+" buttons**. The per-row one (`.fireflyer-add-cell`) adds a chart to that row (the add modal). The insert-strip one (`.fireflyer-add-row`) opens a small **menu — chart / header / separator**: chart uses the add modal (`build_add_form` → `/chart/config/create`), while header and separator insert directly via `config_edit.insert_layout_item` → `/chart/config/insert-item` (a header defaults to text "New header"). A header can be **renamed in place** — double-click it in edit mode (`config_edit.set_header_text` → `/chart/config/header`, located by header index). **Headers and separators also get the hover toolbar** (compact badge, top-right) with **move / edit / delete**, addressed by their **layout-item index** (not a chart id): move → `config_edit.move_layout_item` (`/chart/config/move-item`), delete → `config_edit.delete_layout_item` (`/chart/config/delete-item`, confirm dialog). Edit is **header-only** (opens the same inline rename) — a separator has **no edit button** (and its wrapper gets padding so the thin `<hr>` is hoverable; the badge is centred on the item's top edge). Header edit mirrors move mode's focus feel: the dashboard gets `.ff-focus-mode` (dims everything but the edited header, suppresses hover UI, hides add strips) and the same topbar cancel button shows — `currentHeaderFinish` lets the button (mousedown+`preventDefault` so it cancels instead of blur-saving) or Esc restore the original; Enter/blur saves. Their **move is between-rows only**: it skips `buildMoveZones` and reuses the lit add-row strips as the sole drop targets (`enterItemMove` also hides the two strips flanking the moved item, which would be no-op drops; the shared move state is `moveCid` for charts vs `moveItemIndex` for items, unified by `inMove()`). Charts are rearranged via a **move mode** (not native drag-and-drop): a chart's **move** button lights the chart, dims the rest, and turns off every other interaction (resize, edit, add, crossfilter). Every valid spot lights up as a **blue drop-zone box** — a client-built overlay of column zones (before/after each cell; geometry-derived so merged/spanning cells align) plus **add-row strips in every gap between layout items** (drop into a new row there — `data-before` is a **layout-item index** into the full rows+headers+separators list, so it works around headers and separators too, not just between rows). The hovered box goes solid as a placement preview. The zones follow the **8 move-mode rules documented in `architecture.md`** — the client build is in `buildMoveZones`: side zones on every chart's left/right edge (`move_placement` → `/chart/config/move`); **common borders dedup** to one drop (`dedupBorders` keeps the taller candidate, so a **merged** chart's full-height side wins → dropping there adopts the span); between-rows via the add-row strips/internal gaps (`move_to_new_row` → `/chart/config/new-row`); a single **merge-down** bar (`.ff-move-zone-span`) down the *moved* chart's centre into the row below — `config_edit.merge_down` → `/chart/config/merge-down` adds a bare occurrence so *that chart's own* span grows down one row (only the moved chart, only downward, only one per dashboard); and the moved chart gets **no side zones**, nor its shared borders — **except** a **merged** moved chart, whose shared borders stay as **per-row unmerge** zones (`srcMerged` gate); dropping one is a plain `move_placement` that lands it single-row in that row (span removed). `dedupBorders` never collapses a border involving a merged chart. `move_span` still exists (place a chart spanning a target's whole span + 1 below) and is tested, but the editor's merge gesture now uses `merge_down`. If moving a member out breaks a span, `_finalize` repairs it by collapsing the broken span into its fullest remaining row. Inserts use **width 1** (`:1`); the first row's sizes drive the layout. **Column resize** on a merge group posts to `config_edit.resize_columns` (route `/chart/config/resize-columns`): it recomputes each cell's width from the fine (union) columns it spans, so dragging a boundary updates every row those columns belong to — even from an inherited/lower row — and spanning cells stay bare. Esc/Cancel exits; an emptied source row is dropped. Move-mode clicks/mousedowns are captured (`stopPropagation`) so nothing else fires. `add_chart` generates a unique id, appends the chart block, and splices a placement into the `dashboard:` list (flow-style rows only). All gated by `editing`.
- To add a widget type: implement a `Param` subclass in `params.py`, then reference it from a chart's `PARAMS`. Follow [`fireflyer/PARAM_SKILL.md`](fireflyer/PARAM_SKILL.md) — the guide to the `Param` contract, wiring a chart's `PARAMS`, the surgical save path, and the sync-guard test (the `param` Claude Code skill points at this same file). Pure logic lives in `params.py`/`config_edit.py` (not `app.py`) so it unit-tests without the web stack.

## Portal mode (`fireflyer/web/portal.py`, `fireflyer/portal.py`)

Opt-in, editor-only. An **owner-approved exception** to the "no persistence/multi-user" anti-goal, scoped to `web/` (same status as the AI assistant and `params.py`). It stores many dashboards in a database and lists them in a gallery, **reusing the existing editor unchanged**.

- **The editor is already stateless** — every `/chart/config/*`, `/execute`, `/dashboard` route takes YAML text in from the browser and returns new YAML; nothing is persisted server-side. Portal only wraps this with a persistence + listing layer, so no existing edit logic changes.
- **Enable it** with `FIREFLYER_PORTAL=1` (the `python -m fireflyer.portal` entrypoint sets it, reads `portal.yaml`, and binds `0.0.0.0`). Off by default: `/` is the usual single-dashboard editor. On: `/` is a **gallery** — a table (name, author, last updated) with per-row **Edit / Clone / Remove** and a top **+ New dashboard** button; New and Clone each prompt for a name in a native `<dialog>` modal (the gallery carries a little vanilla JS, allowed as editor chrome). Routes, all **UUID**-addressed: `POST /new` creates a **blank** dashboard (`_empty_yaml`, valid but no datasets/charts/layout) with the modal name + author; `POST /d/{id}/clone` copies one under a new name (`_set_yaml_name` rewrites the `name:` line); `GET /d/{id}` opens the editor seeded with that row's YAML; `POST /d/{id}/save` validates + persists; `POST /d/{id}/delete` removes. The editor **topbar** has left/right groups filled via `INDEX` placeholders — **left**: `__FF_NAV__` (☰ Dashboards link → `/`, portal) + `__FF_BRAND__` (Fireflyer logo; a link to `/` in portal, a plain span locally) + `__FF_DASH_NAME__` (`#ff-dash-name`, after a dot separator `.ff-sep`) — an **editable** title (`contenteditable`, capped width + ellipsis) two-way-bound to the YAML `name:` key (`yamlName`/`setYamlName`: click to rename → rewrites `name:`; edit `name:` in the YAML → title updates); **right**: `__FF_SAVE__`, Preview, `__FF_THEME__` (theme switch), `__FF_USER_MENU__` (profile). **Save** (`#ff-save`, class `run`) is **hidden until there are unsaved changes** (`updateSaveState()` compares `codeEl.value` to `savedYaml`; distinct from preview-*stale*), saves on click or ⌘/Ctrl+S, and a `beforeunload` guard warns if you navigate away dirty. The **theme switch** (`_theme_switch`, `#theme-switch`) is a **3-segment icon control** — inline-SVG **A / sun / moon** for Auto / Light / Dark, no text labels (`title`/`aria-label` carry meaning; icons use `stroke=currentColor` so they follow the segment colour); in **portal** mode it lives inside the profile dropdown (passed as `_user_menu(..., extra=)`, so `__FF_THEME__` is empty), and in **local** mode (no profile) it's standalone in the topbar (`__FF_THEME__`). Exactly one `#theme-switch` per page. There is **no Run button or status text**: instead the output pane (`#output-pane`, class `output`) shows a **↻ Refresh** overlay (`#refresh`, centered both axes, `clamp`-sized) that appears — over a greyed-out, stale preview — only after a **manual** YAML edit (`codeEl` `input` → `markStale()` adds `.stale`); `run()` re-renders and clears it. The stale preview is greyed **but stays interactive** (`.stale .pane-body` must *not* set `pointer-events: none` — that blocked the row/column resize handles; the resize/move handlers read the live textarea and re-render on release, so acting on a stale preview is consistent). Programmatic edits (chat, config-edit) call `run()` directly so never go stale. Rare config-edit error messages use a transient bottom toast (`#ff-toast`, `flash()`) instead of the old status line.
- **The dashboard name is the YAML's required top-level `name:` key** — part of the definition, not portal metadata, so local and portal mode share one format. `Dashboard.from_yaml` **requires** a non-empty `name` (parsed into `Dashboard.name`; missing/empty raises `DashboardError`). Name is a **DB column** but re-derived from the YAML on every `create(yaml, author)`/`save(id, yaml)`, so editing the `name:` key in the editor renames the listing; New/Clone write the modal name into the YAML. **`author`** is a separate column (the logged-in user via `_current_author`, set at create/clone, untouched by saves). `web/chat.py`'s DSL prompt teaches `name:` so the assistant emits it, and `architecture.md` documents it under "File shape" and the "Portal mode" section.
- **Dashboards are stored as an opaque YAML text blob**, validated by `Dashboard.from_yaml` on save — never decomposed into normalized tables (that would break the surgical-edit / comment-preservation design). Datasets stay inline in the YAML for now (CSV paths on the server filesystem); per-dataset storage is deliberately out of scope for the first cut.
- **Two stores in `web/portal.py`**: stdlib **`SqliteStore`** (in-memory for tests, a local file for dev) and **`PostgresStore`** (runtime; imports `psycopg` lazily). `make_store(dsn)` picks by DSN. The driver is an optional **`.[portal]`** extra, so `pip install -e ".[test]"` and CI stay database-free. Store logic + the gallery HTML live in `portal.py` (not `app.py`) so they **unit-test without the web stack** — `tests/test_portal.py` uses in-memory sqlite and never touches a live DB (same rule as the chat tests).
- **Auth (`web/auth.py`)** — portal mode is gated behind a login; default **admin/admin** (`FIREFLYER_USER`/`FIREFLYER_PASSWORD`). Intentionally minimal, built on **two independent seams** so SSO/LDAP drop in without touching routes: (1) the `Authenticator` protocol — `verify(user, pass) -> identity | None`, default `PasswordAuthenticator`, swap via `app.state.authenticator`; (2) an HMAC-signed session cookie (`set_session`/`current_user`/`clear_session`, secret from `FIREFLYER_SECRET`), independent of *how* the identity was proven — an SSO callback just calls `set_session`. A middleware guard redirects anon requests to `/login`; routes are `GET/POST /login`, `POST /logout`. The **profile button** (`auth.user_menu(identity, extra="")`) is a native `<details>` dropdown (username → optional `extra` — the editor passes the theme switch → **Log out**), styled by `auth.PROFILE_CSS` (injected into both the editor `INDEX` and the gallery, since there's no shared stylesheet); it sits top-right in the gallery (`render_gallery(..., user_menu)`, no `extra`) and the editor (`__FF_USER_MENU__` slot). Auth is on iff portal is; local mode has no login. Pure functions in `auth.py`, unit-tested without the web stack (`tests/test_auth.py`). **The SSO/OAuth extension recipe is in `architecture.md` → "Portal mode → Authentication".**

## Non-negotiable constraints (from architecture.md)

Explicit anti-goals. Do not add them, even if they seem like good engineering:

- **No abstractions for future flexibility.** No service layers, repositories, registries, plugin frameworks, or DI containers until actually needed. The dashboard's `type → class` lookup is a plain dict — keep it that way. (One **deliberate, owner-approved exception**: the `params.py` widget layer, which exists to power the editor's edit modal. It earns its keep — don't take it as license for more abstractions.)
- **No frontend tooling.** No npm, webpack, vite, tailwind, or bootstrap. PicoCSS-compatible markup and inline SVG only.
- **No production concerns.** No auth, multi-user, caching, streaming, large-dataset optimization, or realtime updates.
- **No production concerns.** No auth, multi-user, caching, streaming, large-dataset optimization, or realtime updates. (One **owner-approved exception**: **portal mode** — see below — adds DB persistence + a dashboard listing, scoped to `web/`. It does *not* license auth, caching, or the rest.)
- **No chart features beyond the MVP spec.** Aggregation is count-only for pie/bar/map. Joins, calculated columns, SQL, and export are out of scope. Table reads at most the first 1000 rows.

When in doubt: less code, fewer abstractions, hardcoded behavior, developer experience over architectural purity. The MVP is expected to be rewritten — if a solution feels generic, configurable, or extensible, it's probably wrong for this stage.
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ WORKDIR /app
# them present at build time.
COPY pyproject.toml LICENSE README.md ./
COPY fireflyer ./fireflyer
RUN pip install --no-cache-dir -e ".[test]"
RUN pip install --no-cache-dir -e ".[test,portal]"

# Sample data the default dashboard references (files/orders.csv).
COPY files ./files
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,11 @@ Each chart's full options live in its spec: [`fireflyer/chart/<name>/spec.md`](f

## Dashboards

A dashboard is **one YAML file** that declares its datasets, its charts, and how they lay out on a page:
A dashboard is **one YAML file** that declares its name, its datasets, its charts, and how they lay out on a page:

```yaml
name: Orders overview

datasets:
orders:
path: files/orders.csv
Expand Down
Loading
Loading