Compare commits

..

18 Commits

Author SHA1 Message Date
0a38def5a5 Complete Economic Observatory MVP (ADAPTIVE-WP-0002)
Add file-based Bubble, Stripe, and OpenRouter importers; usage attribution,
cost allocation, pricing simulator, credit wallets, and recommendations in the
dashboard API. Document whynot-design UI workflow and archive the finished
workplan with all ten tasks marked done.
2026-06-22 23:23:31 +02:00
04ee6d2421 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-22:
  - update .custodian-brief.md for adaptive-pricing
2026-06-22 23:23:14 +02:00
4ef04dd5e5 observatory stuff 2026-06-22 23:05:05 +02:00
1bdb518a94 Human-review .repo-classification.yaml (CUST-WP-0050 follow-up) 2026-06-22 17:56:16 +02:00
f8bd6f912f Add .repo-classification.yaml (CUST-WP-0050 T11 agent first-pass) 2026-06-22 17:47:34 +02:00
a1a90a9504 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-22:
  - update .custodian-brief.md for adaptive-pricing
2026-06-22 17:05:08 +02:00
da3b7d66f0 Integrate whynot-design into Economic Observatory UI
Vendor whynot-design Layer 1 (tokens, CSS) and Layer 2 (<wn-*>
components) via scripts/sync-whynot-design.sh with a pinned ref.
Migrate the observatory shell to canonical web components, keep
observatory-specific layout in styles.css, and add vendor integrity
tests plus correct JS MIME types on the dev server.
2026-06-22 03:09:44 +02:00
9c1c2142fc Add Economic Observatory web UI with ledger-backed API
Introduce ui/ dashboard (dark observatory layout), JSON API, and local
dev server. All metrics load from expense and payment record ledgers.
Links Claude design reference for visual alignment.
2026-06-22 02:48:52 +02:00
7b84d34ea6 Record actual Stripe payment costs for tegwick membership
Update payment_records to 8.99 EUR gross, 0.44 EUR fees, 8.55 EUR net
payout to binky-hedgehog. Link member tegwick in membership ledger and
add Stripe reference catalog.
2026-06-22 02:36:42 +02:00
bb3f152846 Add railiance01 hosting at 8.99 EUR/mo from March 2026
Expense records and virtual server catalog entry for railiance01,
active from 2026-03 alongside existing domain and coulombcore costs.
2026-06-22 02:21:55 +02:00
fc2324692c Add coulombcore hosting expense at 13.99 EUR/mo from Jan 2025
One hosting expense record per month alongside domain rows. Update
infrastructure catalog, tests, and dashboard for 20.74 EUR/mo total
infrastructure.
2026-06-22 02:20:28 +02:00
86ce511764 Replace infrastructure costs with actual domain invoice data
Start ledger January 2025. Record coulomb.social (3.75 EUR/mo) and
coulomb.pro (3.00 EUR/mo) as per-domain expense rows. Remove Bubble and
placeholder overhead. Add infrastructure reference catalogs; virtual server
records pending invoice data.
2026-06-22 02:14:06 +02:00
31db9f8f31 Refactor economics to expense-record ledger with correct Bubble cost
Replace pre-aggregated costs.json with expense_records.json (48 line-item
records) and payment_records.json. All monthly and cumulative totals are
computed deterministically in observatory/ledger.py. Correct Bubble.io to
$32/mo (since Feb 2025) — infrastructure €69.44/mo not €72.20.
2026-06-22 02:03:22 +02:00
ea2c2c6403 Fix cumulative platform cost Stripe double-counting
Split infrastructure vs payment-processing costs. Liquidity burn now
uses infrastructure cash out only (€1,155.20 cumulative) because Stripe
fees are already deducted from net member payments. Total platform cost
(€1,158.24) remains visible for gross-margin economics.
2026-06-22 01:51:53 +02:00
fe2174f37a Add liquidity tracking, budget, and platform cost history
Restore operator platform costs (Bubble, domains, Stripe) with monthly
history from March 2025 and member payments from November 2025. Track
€1,000 starting budget, cumulative burn, and remaining liquidity in the
economics dashboard. Document LQ requirements in REQUIREMENTS.md.
2026-06-22 01:48:45 +02:00
8f42220d81 Adapt economics dashboard to sole-member zero-cost reality
Update membership, revenue, and cost registries to one active member with
no running costs. Refresh dashboard report and tests for €8.99 revenue and
100% gross margin.
2026-06-22 01:41:59 +02:00
a1a4aa972f Implement ADAPTIVE-WP-0002 Sprint 1 economic foundations
Add Coulomb observatory package with JSON registries (product, pricing
models, costs, revenue, membership), economics snapshot engine, Economics
Dashboard v1 CLI, sample report, and pytest coverage. Complete T01 and
queue Sprint 2 Bubble.io integration.
2026-06-22 01:32:48 +02:00
d648a3263d chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-22:
  - update .custodian-brief.md for adaptive-pricing
2026-06-22 01:32:42 +02:00
81 changed files with 6983 additions and 145 deletions

20
.claude/rules/agents.md Normal file
View File

@@ -0,0 +1,20 @@
## Kaizen Agents
Specialized agent personas available on demand via the state-hub MCP.
**Discover:** `list_kaizen_agents()` — returns all agents with name, description, category
**Load:** `get_kaizen_agent("tdd-workflow")` — returns full instructions; read and follow them
Common agents:
| Agent | Category | When to use |
|-------|----------|-------------|
| `tdd-workflow` | testing | Step-by-step TDD8 workflow for any feature |
| `code-refactoring` | quality | Code quality analysis and safe refactoring |
| `test-maintenance` | testing | Diagnose and fix failing tests |
| `requirements-engineering` | process | Prevent interface/mock mismatches upfront |
| `keepaTodofile` | process | Maintain TODO.md during work |
| `project-management` | process | Track status, determine next steps |
| `datamodel-optimization` | quality | Optimize dataclasses and data structures |
All 17 agents: call `list_kaizen_agents()` for the full list.

View File

@@ -0,0 +1,8 @@
## Architecture
<!-- TODO: Describe the key design decisions and component structure.
Key modules, data flows, external integrations, state machines, etc. -->
## Quick Reference
`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference

View File

@@ -0,0 +1,38 @@
## First Session Protocol
Triggered when `get_domain_summary("financials")` shows **no workstreams**.
The project is registered but work has not yet been structured.
**Step 1 — Read, don't write**
- `~/the-custodian/canon/projects/financials/project_charter_v0.1.md` — purpose, scope
- `~/the-custodian/canon/projects/financials/roadmap_v0.1.md` — planned phases
- Scan repo root: README, directory structure, existing code or docs
**Step 2 — Survey in-progress work**
Look for TODOs, open branches, half-finished files. Note done vs. started but incomplete.
**Step 3 — Propose workstreams to Bernd**
Propose 13 workstreams — each a coherent strand, weeks to months, anchored to a
roadmap phase. **Wait for approval before creating.**
**Step 4 — Create workplan file first, then DB record (ADR-001)**
```
workplans/ADAPTIVE-WP-NNNN-<slug>.md ← write this first
```
Then register in the hub:
```
create_workstream(topic_id="f39fa2a3-c491-414c-a91b-b4c5fcc6139c", title="...", owner="...", description="...")
create_task(workstream_id="<id>", title="...", priority="high|medium|low")
```
**Step 5 — Record the setup**
```
add_progress_event(
summary="First session: structured financials into N workstreams, M tasks",
event_type="milestone",
topic_id="f39fa2a3-c491-414c-a91b-b4c5fcc6139c",
detail={"workstreams": [...], "tasks_created": M}
)
```
<!-- Delete or archive this file once past first session -->

View File

@@ -0,0 +1,8 @@
## Repo boundary
This repo owns **adaptive-pricing** only. It does not own:
<!-- TODO: List what belongs in adjacent repos, e.g.:
- SSH key management → railiance-infra/
- State hub code → state-hub/
-->

View File

@@ -0,0 +1,5 @@
**Purpose:** Auto-regulating market value exploring price engine.
**Domain:** financials
**Repo slug:** adaptive-pricing
**Topic ID:** f39fa2a3-c491-414c-a91b-b4c5fcc6139c

View File

@@ -0,0 +1,85 @@
## Session Protocol
Dev Hub (State Hub API): http://127.0.0.1:8000
MCP server name in `~/.claude.json`: `dev-hub`
**Step 1 — Orient**
Read the offline-safe brief first — it works without a live hub connection:
```bash
cat .custodian-brief.md
```
Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
```
get_domain_summary("financials")
```
If MCP tools are unavailable in the current agent session, use the REST API:
```bash
curl -s "http://127.0.0.1:8000/state/summary" | python3 -m json.tool
```
If the hub is offline: `cd ~/state-hub && make api`
**Step 2 — Check inbox**
With MCP tools:
```
get_messages(to_agent="adaptive-pricing", unread_only=True)
```
Mark read with `mark_message_read(message_id)`. Reply or act on coordination
requests before proceeding.
Without MCP tools:
```bash
curl -s "http://127.0.0.1:8000/messages/?to_agent=adaptive-pricing&unread_only=true" \
| python3 -m json.tool
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
-H "Content-Type: application/json" -d '{}'
```
**Step 3 — Scan workplans**
```bash
ls workplans/
```
For each file with `status: ready`, `active`, or `blocked`, note pending
`wait`/`todo`/`progress` tasks.
**Step 4 — Present brief**
1. **Active workstreams** for `financials` — title, task counts, blocking decisions
2. **Pending tasks** from `workplans/` + any `[repo:adaptive-pricing]` hub tasks
3. **Goal guidance** — if `goal_guidance` in summary:
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
- `alignment_warnings`: flag if active work is not aligned with current goal
4. **Suggested next action** — highest-priority open item
5. **SBOM status** — flag if `last_sbom_at` is unset for this repo
If no workstreams: follow First Session Protocol (`first-session.md`).
**During work:** `record_decision()` · `add_progress_event()` · `resolve_decision()`
> State Hub is a *read model*. Bootstrap tools (`create_workstream`, `create_task`)
> are First Session Protocol only. Work structure belongs in repo files (ADR-001).
**Session close:**
With MCP tools:
```
add_progress_event(summary="...", topic_id="f39fa2a3-c491-414c-a91b-b4c5fcc6139c", workstream_id="<uuid>")
```
Without MCP tools:
```bash
curl -s -X POST http://127.0.0.1:8000/progress/ \
-H "Content-Type: application/json" \
-d '{"topic_id":"f39fa2a3-c491-414c-a91b-b4c5fcc6139c","workstream_id":"<uuid>","event_type":"note","summary":"what changed","author":"codex"}'
```
If workplan files were modified, ensure the local copy is up to date first:
```bash
git -C <repo_path> pull --ff-only
cd ~/state-hub && make fix-consistency REPO=adaptive-pricing
```
For repos where implementation runs on a remote machine (e.g. CoulombCore),
use the combined target which pulls before fixing:
```bash
cd ~/state-hub && make fix-consistency-remote REPO=adaptive-pricing
```
**C-15** (DB task ahead of file) is normal in multi-machine workflows — writeback
will sync the file to match DB. **C-16** (repo behind remote) blocks all writes
until you pull — intentional to prevent clobbering remote progress.

View File

@@ -0,0 +1,19 @@
## Stack
<!-- TODO: Fill in language, frameworks, and key dependencies -->
- **Language:**
- **Key deps:**
## Dev Commands
```bash
# TODO: Fill in the standard commands for this repo
# Install dependencies
# Run tests
# Lint / type check
# Build / package (if applicable)
```

View File

@@ -0,0 +1,40 @@
## Workplan Convention (ADR-001)
File location: `workplans/ADAPTIVE-WP-NNNN-<slug>.md`
ID prefix: `ADAPTIVE-WP-`
Work items originate as files in this repo **before** being registered in the hub.
Canonical workplan/workstream frontmatter statuses are:
`proposed`, `ready`, `active`, `blocked`, `backlog`, `finished`, `archived`.
Use `proposed` for a newly drafted plan, `ready` after review against current
repo state, and `finished` when implementation is complete. `stalled` and
`needs_review` are derived health labels, not stored statuses.
Closed workplans may be moved to `workplans/archived/` with a completion-date
prefix: `YYMMDD-ADAPTIVE-WP-NNNN-<slug>.md`. The frontmatter id remains
unchanged; the prefix is only for quick visual reference.
Small opportunistic tasks discovered during another session use **Ad Hoc Tasks**:
`workplans/ADHOC-YYYY-MM-DD.md`, workstream slug `adhoc-YYYY-MM-DD`, and task ids
`ADHOC-YYYY-MM-DD-T01`, `T02`, etc. Use adhocs only for low-risk work completed
directly. Promote anything requiring analysis, design, approval, dependencies, or
multiple planned phases into a normal workplan.
Ecosystem todos from other agents arrive as `[repo:adaptive-pricing]` hub tasks —
visible at session start. Pick one up by creating the workplan file, then registering
the workstream.
Task blocks use this shape:
```task
id: ADAPTIVE-WP-NNNN-T01
status: wait | todo | progress | done | cancel
priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
```
Status progression is `todo``progress``done`; use `wait` for waiting or
blocked work and `cancel` for stopped work.
<!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here -->

View File

@@ -1,28 +1,18 @@
<!-- custodian-brief: generated by fix-consistency — do not edit manually --> <!-- custodian-brief: generated by fix-consistency — do not edit manually -->
# Custodian Brief — adaptive-pricing # Custodian Brief — adaptive-pricing
**Domain:** helix_forge **Domain:** infotech
**Last synced:** 2026-06-21 23:32 UTC **Last synced:** 2026-06-22 21:23 UTC
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
## Active Workstreams ## Active Workstreams
### Economic Observatory MVP (Coulomb Social) *(none — repo may need first-session setup)*
Progress: 1/8 done | workstream_id: `9e0b7784-702a-4bc7-b7a1-3ff801f9c768`
**Open tasks:**
- ! Sprint 2 — Bubble.io Integration `42c181f9`
- ! Sprint 3 — Stripe Integration `c7e308bc`
- ! Sprint 4 — OpenRouter Cost Attribution `b2b61910`
- ! Sprint 5 — Cost Allocation Engine `906009be`
- ! Sprint 6 — Pricing Simulator `cb735e7d`
- ! Sprint 7 — Membership Credit System `aa8efb52`
- ! Sprint 8 — Adaptive Pricing Prototype `d8195bf0`
--- ---
## MCP Orientation (when available) ## MCP Orientation (when available)
If the state-hub MCP server is reachable, call: If the state-hub MCP server is reachable, call:
`get_domain_summary("helix_forge")` `get_domain_summary("infotech")`
This provides richer cross-domain context. This provides richer cross-domain context.
If the MCP call fails, use this file as your orientation source. If the MCP call fails, use this file as your orientation source.

27
.repo-classification.yaml Normal file
View File

@@ -0,0 +1,27 @@
repo_classification:
standard: Repo Classification Standard
version: '1.0'
classified_at: '2026-06-22'
classified_by: human
category: product
domain: financials
secondary_domains:
- infotech
- agents
capability_tags:
- pricing
- monetization
- lifecycle
- decision-support
- product-development
business_stake:
- finance
- product
- sales
- intelligence
- automation
business_mechanics:
- intention
- control
- adaptation
notes: Adaptive pricing product; standard §13.6 — human confirmed.

View File

@@ -4,36 +4,13 @@
**Purpose:** Auto-regulating market value exploring price engine. **Purpose:** Auto-regulating market value exploring price engine.
**Domain:** helix_forge **Domain:** financials
**Repo slug:** adaptive-pricing **Repo slug:** adaptive-pricing
**Topic ID:** `f39fa2a3-c491-414c-a91b-b4c5fcc6139c` **Topic ID:** `f39fa2a3-c491-414c-a91b-b4c5fcc6139c`
**Workplan prefix:** `ADAPTIVE-WP-` **Workplan prefix:** `ADAPTIVE-WP-`
--- ---
## Dev Workflow
The repository is in an **early framework phase**: Markdown documentation, research
notes, and capability registry YAML. No application runtime, package manifest, or
automated test suite exists yet. Executable implementation begins under
`workplans/ADAPTIVE-WP-0002-economic-observatory-mvp.md`.
| Need | Command |
|------|---------|
| Install | none — no runtime dependencies |
| Test | none configured yet |
| Lint / format | none configured — match surrounding Markdown style |
| Build | none — documentation-only repo |
| Run | none |
| Workplan / hub sync | `cd ~/state-hub && make fix-consistency REPO=adaptive-pricing REPO_PATH=~/adaptive-pricing` |
| Registry sanity | `grep -q '^version:' registry/indexes/capabilities.yaml && echo OK` |
**Verify a change before declaring it done:** run `make fix-consistency` (expect
PASS), and confirm edited docs stay aligned with `INTENT.md` and
`docs/ProductRequirementsDocument.md`.
---
## State Hub Integration ## State Hub Integration
The Custodian State Hub tracks work across all domains. Interact via HTTP REST — The Custodian State Hub tracks work across all domains. Interact via HTTP REST —
@@ -175,8 +152,6 @@ get wrong.
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml` **Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
---
<!-- REPO-AGENTS-EXTENSIONS --> <!-- REPO-AGENTS-EXTENSIONS -->
<!-- Append repo-specific agent instructions below this marker. <!-- Append repo-specific agent instructions below this marker.
The state-hub template sync preserves content after this line. --> The state-hub template sync preserves content after this line. -->
@@ -191,7 +166,8 @@ get wrong.
Do not place project-specific MVP documentation in `specs/` or other generic Do not place project-specific MVP documentation in `specs/` or other generic
paths. The Coulomb Social MVP lives under `projects/coulomb-pricing/`; its paths. The Coulomb Social MVP lives under `projects/coulomb-pricing/`; its
workplan remains `workplans/ADAPTIVE-WP-0002-economic-observatory-mvp.md`. Coulomb MVP workplan is archived at
`workplans/archived/260622-ADAPTIVE-WP-0002-economic-observatory-mvp.md`.
--- ---

12
CLAUDE.md Normal file
View File

@@ -0,0 +1,12 @@
# adaptive-pricing — Claude Code Instructions
@SCOPE.md
@.claude/rules/repo-identity.md
@.claude/rules/session-protocol.md
@.claude/rules/first-session.md
@.claude/rules/workplan-convention.md
@.claude/rules/stack-and-commands.md
@.claude/rules/architecture.md
@.claude/rules/repo-boundary.md
@.claude/rules/credential-routing.md
@.claude/rules/agents.md

View File

@@ -19,5 +19,5 @@ pricing to payment-provider execution.
## Status ## Status
Early framework phase (documentation and research). First implementation: Early framework phase (documentation and research). First implementation:
[Economic Observatory MVP](workplans/ADAPTIVE-WP-0002-economic-observatory-mvp.md) [Economic Observatory MVP](workplans/archived/260622-ADAPTIVE-WP-0002-economic-observatory-mvp.md) (finished)
for Coulomb Social. for Coulomb Social.

View File

@@ -0,0 +1,19 @@
.PHONY: help design test serve
.DEFAULT_GOAL := help
REF ?=
help: ## List available make targets
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}'
design: ## Re-vendor whynot-design (optional: REF=v0.2.1 or commit SHA)
./scripts/sync-whynot-design.sh $(REF)
python3 -m pytest -q tests/test_ui_vendor.py
test: ## Run the full pytest suite
python3 -m pytest -q
serve: ## Start the Economic Observatory UI on :8765
python3 -m observatory.server

View File

@@ -2,11 +2,62 @@
Project-specific material for the Coulomb Social Economic Observatory MVP. Project-specific material for the Coulomb Social Economic Observatory MVP.
This directory holds implementation artifacts, integrations, and documentation that Generic adaptive-pricing framework concepts belong in the repository root
apply to the Coulomb deployment only. Generic adaptive-pricing framework concepts (`INTENT.md`, `docs/`, `research/`, `registry/`). Execution tracking:
belong in the repository root (`INTENT.md`, `docs/`, `research/`, `registry/`). `workplans/archived/260622-ADAPTIVE-WP-0002-economic-observatory-mvp.md`
(finished 2026-06-22).
**Execution tracking:** `workplans/ADAPTIVE-WP-0002-economic-observatory-mvp.md` Liquidity and cost requirements: `REQUIREMENTS.md`.
UI workflow (whynot-design): `docs/UI-WORKFLOW.md`.
**Strategic positioning:** Adaptive Pricing is the pricing capability layer for ## Economic Observatory
Coulomb offerings and related product ecosystems.
The `observatory/` package reads **expense and payment record ledgers** and
computes all totals programmatically (`ledger.py``economics.py`).
| Ledger | File |
|--------|------|
| Budget (€1,000 start) | `data/budget.json` |
| Expense records | `data/expense_records.json` |
| Payment records | `data/payment_records.json` |
| Product model | `data/product.json` |
| Pricing models | `data/pricing-models.json` |
| Membership | `data/membership.json` |
| AI usage | `data/usage_records.json` |
| Credit wallets | `data/credit_wallets.json` |
| Value range hypotheses | `data/value_range.json` |
| Market signals | `data/market_signals.json` |
**Current reality:** infrastructure from January 2025 — domains **€6.75/mo**,
coulombcore hosting **€13.99/mo** (from Jan 2025), railiance01 hosting
**€8.99/mo** (from Mar 2026). Member **tegwick** pays **€8.99/mo** (Stripe fee
**€0.44**, net payout **€8.55** to binky-hedgehog) from November 2025. Customer
cost-pass-through billing is not active.
### Commands
```bash
cd projects/coulomb-pricing
make test
make design # pull whynot-design tokens/CSS/components
make design REF=v0.2.1 # bump to a tag or commit
python3 -m observatory --period 2026-06
python3 -m observatory --period 2026-06 --output reports/economics-2026-06.md
make serve
```
Open **http://127.0.0.1:8765/** for the Economic Observatory UI (served from
`ui/`, data via `/api/dashboard`). The UI consumes **whynot-design** (Layer 1 CSS
+ Layer 2 `<wn-*>` components) from `ui/vendor/whynot-design/`. See
`docs/UI-WORKFLOW.md` for the implementation process.
### Importers (file-based sync)
```bash
python3 -m observatory.importers.bubble --input data/imports/bubble-export.sample.json
python3 -m observatory.importers.stripe --input data/imports/stripe-export.sample.json
python3 -m observatory.importers.openrouter --input data/imports/openrouter-export.sample.json
```
Sample exports live under `data/imports/`. Live API sync can replace these
file-based importers in a follow-on workplan.

View File

@@ -0,0 +1,83 @@
# Coulomb MVP — Liquidity & Cost Requirements
## Context
Coulomb Social carries **operator infrastructure costs** (domains, virtual
servers, payment-processing fees, future OpenRouter usage) while **customer
cost-pass-through billing is not active yet**. Members pay a flat subscription; they are not billed for
underlying platform spend.
The Economic Observatory must make the resulting **liquidity burn** visible.
## Requirements
### LQ-001 — Expense record ledger (source of truth)
Capture every operator expense as an individual record in
`data/expense_records.json`. Fields: `period`, `vendor`, `amount`, `currency`,
`cost_class`, `source`.
**Never store hand-calculated monthly or cumulative totals in data files.**
All aggregations must be computed programmatically by `observatory/ledger.py`.
### LQ-002 — Payment record ledger
Capture member subscription payments in `data/payment_records.json` with gross,
fees, and net amounts per period. Payment-processing totals are summed from
`fees_amount` — not duplicated as expense records.
### LQ-003 — Budget tracking
Maintain an operator liquidity budget (initial: **€1,000**) and compute
remaining budget after cumulative **infrastructure** spend minus cumulative net
member payments received.
### LQ-004 — Liquidity position
Report whether the project is **burning**, **neutral**, or **generating**
liquidity each period:
- `period_net = net_member_payments - infrastructure_cost`
- `cumulative_net = sum(period_net)`
- `remaining_budget = initial_budget + cumulative_net`
**No double-counting:** payment-processing fees are deducted from net member
payments. They are summed separately for economics reporting but must **not** be
subtracted again in the liquidity formula.
- `total_platform_cost = infrastructure_cost + payment_processing_cost` (for
gross-margin economics vs gross revenue)
- `cumulative_total_platform_cost` is informational; liquidity burn uses
`cumulative_infrastructure_cost` only
### LQ-005 — No customer cost billing (MVP boundary)
Do not allocate platform costs to customer invoices in MVP. Cost attribution
(OpenRouter per member, usage overage) is observatory-only until a later phase
introduces customer-visible credits or usage billing.
### LQ-006 — Historical dashboard
Economics Dashboard must show:
- Current-period economics (revenue, platform cost, margin)
- Cumulative liquidity summary (budget, burn, remaining)
- Monthly history table computed from ledgers
### LQ-007 — Deterministic calculation engine
All economics and liquidity figures must be produced by the Python observatory
package (`ledger.py`, `economics.py`). LLM or manual arithmetic must not be the
source of aggregated totals.
## Data sources (current)
| Ledger | Path |
|--------|------|
| Budget | `data/budget.json` |
| Expense records | `data/expense_records.json` |
| Payment records | `data/payment_records.json` |
| Membership | `data/membership.json` |
Future sprints replace manual records with Bubble, Stripe, and OpenRouter imports
while preserving the same ledger schema and calculation rules.

View File

@@ -0,0 +1,7 @@
{
"version": 1,
"currency": "EUR",
"initial_budget": "1000.00",
"started": "2025-01",
"note": "Operator liquidity pool for Coulomb Social MVP platform spend before the offering is cash-flow positive."
}

View File

@@ -0,0 +1,11 @@
{
"version": 1,
"currency": "EUR",
"wallets": [
{
"member_id": "member-tegwick",
"monthly_allowance_eur": "2.00",
"note": "Observatory-only allowance for hybrid pricing experiments"
}
]
}

View File

@@ -0,0 +1,586 @@
{
"version": 2,
"records": [
{
"id": "exp-domain-social-2025-01",
"period": "2025-01",
"vendor": "domain.coulomb.social",
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.75",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-pro-2025-01",
"period": "2025-01",
"vendor": "domain.coulomb.pro",
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.00",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-hosting-coulombcore-2025-01",
"period": "2025-01",
"vendor": "hosting.coulombcore",
"description": "coulombcore virtual server hosting",
"cost_class": "infrastructure",
"amount": "13.99",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-social-2025-02",
"period": "2025-02",
"vendor": "domain.coulomb.social",
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.75",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-pro-2025-02",
"period": "2025-02",
"vendor": "domain.coulomb.pro",
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.00",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-hosting-coulombcore-2025-02",
"period": "2025-02",
"vendor": "hosting.coulombcore",
"description": "coulombcore virtual server hosting",
"cost_class": "infrastructure",
"amount": "13.99",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-social-2025-03",
"period": "2025-03",
"vendor": "domain.coulomb.social",
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.75",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-pro-2025-03",
"period": "2025-03",
"vendor": "domain.coulomb.pro",
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.00",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-hosting-coulombcore-2025-03",
"period": "2025-03",
"vendor": "hosting.coulombcore",
"description": "coulombcore virtual server hosting",
"cost_class": "infrastructure",
"amount": "13.99",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-social-2025-04",
"period": "2025-04",
"vendor": "domain.coulomb.social",
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.75",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-pro-2025-04",
"period": "2025-04",
"vendor": "domain.coulomb.pro",
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.00",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-hosting-coulombcore-2025-04",
"period": "2025-04",
"vendor": "hosting.coulombcore",
"description": "coulombcore virtual server hosting",
"cost_class": "infrastructure",
"amount": "13.99",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-social-2025-05",
"period": "2025-05",
"vendor": "domain.coulomb.social",
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.75",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-pro-2025-05",
"period": "2025-05",
"vendor": "domain.coulomb.pro",
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.00",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-hosting-coulombcore-2025-05",
"period": "2025-05",
"vendor": "hosting.coulombcore",
"description": "coulombcore virtual server hosting",
"cost_class": "infrastructure",
"amount": "13.99",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-social-2025-06",
"period": "2025-06",
"vendor": "domain.coulomb.social",
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.75",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-pro-2025-06",
"period": "2025-06",
"vendor": "domain.coulomb.pro",
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.00",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-hosting-coulombcore-2025-06",
"period": "2025-06",
"vendor": "hosting.coulombcore",
"description": "coulombcore virtual server hosting",
"cost_class": "infrastructure",
"amount": "13.99",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-social-2025-07",
"period": "2025-07",
"vendor": "domain.coulomb.social",
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.75",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-pro-2025-07",
"period": "2025-07",
"vendor": "domain.coulomb.pro",
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.00",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-hosting-coulombcore-2025-07",
"period": "2025-07",
"vendor": "hosting.coulombcore",
"description": "coulombcore virtual server hosting",
"cost_class": "infrastructure",
"amount": "13.99",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-social-2025-08",
"period": "2025-08",
"vendor": "domain.coulomb.social",
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.75",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-pro-2025-08",
"period": "2025-08",
"vendor": "domain.coulomb.pro",
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.00",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-hosting-coulombcore-2025-08",
"period": "2025-08",
"vendor": "hosting.coulombcore",
"description": "coulombcore virtual server hosting",
"cost_class": "infrastructure",
"amount": "13.99",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-social-2025-09",
"period": "2025-09",
"vendor": "domain.coulomb.social",
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.75",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-pro-2025-09",
"period": "2025-09",
"vendor": "domain.coulomb.pro",
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.00",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-hosting-coulombcore-2025-09",
"period": "2025-09",
"vendor": "hosting.coulombcore",
"description": "coulombcore virtual server hosting",
"cost_class": "infrastructure",
"amount": "13.99",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-social-2025-10",
"period": "2025-10",
"vendor": "domain.coulomb.social",
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.75",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-pro-2025-10",
"period": "2025-10",
"vendor": "domain.coulomb.pro",
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.00",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-hosting-coulombcore-2025-10",
"period": "2025-10",
"vendor": "hosting.coulombcore",
"description": "coulombcore virtual server hosting",
"cost_class": "infrastructure",
"amount": "13.99",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-social-2025-11",
"period": "2025-11",
"vendor": "domain.coulomb.social",
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.75",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-pro-2025-11",
"period": "2025-11",
"vendor": "domain.coulomb.pro",
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.00",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-hosting-coulombcore-2025-11",
"period": "2025-11",
"vendor": "hosting.coulombcore",
"description": "coulombcore virtual server hosting",
"cost_class": "infrastructure",
"amount": "13.99",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-social-2025-12",
"period": "2025-12",
"vendor": "domain.coulomb.social",
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.75",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-pro-2025-12",
"period": "2025-12",
"vendor": "domain.coulomb.pro",
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.00",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-hosting-coulombcore-2025-12",
"period": "2025-12",
"vendor": "hosting.coulombcore",
"description": "coulombcore virtual server hosting",
"cost_class": "infrastructure",
"amount": "13.99",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-social-2026-01",
"period": "2026-01",
"vendor": "domain.coulomb.social",
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.75",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-pro-2026-01",
"period": "2026-01",
"vendor": "domain.coulomb.pro",
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.00",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-hosting-coulombcore-2026-01",
"period": "2026-01",
"vendor": "hosting.coulombcore",
"description": "coulombcore virtual server hosting",
"cost_class": "infrastructure",
"amount": "13.99",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-social-2026-02",
"period": "2026-02",
"vendor": "domain.coulomb.social",
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.75",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-pro-2026-02",
"period": "2026-02",
"vendor": "domain.coulomb.pro",
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.00",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-hosting-coulombcore-2026-02",
"period": "2026-02",
"vendor": "hosting.coulombcore",
"description": "coulombcore virtual server hosting",
"cost_class": "infrastructure",
"amount": "13.99",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-social-2026-03",
"period": "2026-03",
"vendor": "domain.coulomb.social",
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.75",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-pro-2026-03",
"period": "2026-03",
"vendor": "domain.coulomb.pro",
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.00",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-hosting-coulombcore-2026-03",
"period": "2026-03",
"vendor": "hosting.coulombcore",
"description": "coulombcore virtual server hosting",
"cost_class": "infrastructure",
"amount": "13.99",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-hosting-railiance01-2026-03",
"period": "2026-03",
"vendor": "hosting.railiance01",
"description": "railiance01 virtual server hosting",
"cost_class": "infrastructure",
"amount": "8.99",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-social-2026-04",
"period": "2026-04",
"vendor": "domain.coulomb.social",
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.75",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-pro-2026-04",
"period": "2026-04",
"vendor": "domain.coulomb.pro",
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.00",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-hosting-coulombcore-2026-04",
"period": "2026-04",
"vendor": "hosting.coulombcore",
"description": "coulombcore virtual server hosting",
"cost_class": "infrastructure",
"amount": "13.99",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-hosting-railiance01-2026-04",
"period": "2026-04",
"vendor": "hosting.railiance01",
"description": "railiance01 virtual server hosting",
"cost_class": "infrastructure",
"amount": "8.99",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-social-2026-05",
"period": "2026-05",
"vendor": "domain.coulomb.social",
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.75",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-pro-2026-05",
"period": "2026-05",
"vendor": "domain.coulomb.pro",
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.00",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-hosting-coulombcore-2026-05",
"period": "2026-05",
"vendor": "hosting.coulombcore",
"description": "coulombcore virtual server hosting",
"cost_class": "infrastructure",
"amount": "13.99",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-hosting-railiance01-2026-05",
"period": "2026-05",
"vendor": "hosting.railiance01",
"description": "railiance01 virtual server hosting",
"cost_class": "infrastructure",
"amount": "8.99",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-social-2026-06",
"period": "2026-06",
"vendor": "domain.coulomb.social",
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.75",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-domain-pro-2026-06",
"period": "2026-06",
"vendor": "domain.coulomb.pro",
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
"cost_class": "infrastructure",
"amount": "3.00",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-hosting-coulombcore-2026-06",
"period": "2026-06",
"vendor": "hosting.coulombcore",
"description": "coulombcore virtual server hosting",
"cost_class": "infrastructure",
"amount": "13.99",
"currency": "EUR",
"source": "invoice"
},
{
"id": "exp-hosting-railiance01-2026-06",
"period": "2026-06",
"vendor": "hosting.railiance01",
"description": "railiance01 virtual server hosting",
"cost_class": "infrastructure",
"amount": "8.99",
"currency": "EUR",
"source": "invoice"
}
],
"note": "Infrastructure expense ledger: domains and virtual server hosting. Totals computed by observatory/ledger.py."
}

View File

@@ -0,0 +1,12 @@
{
"exported_at": "2026-06-22",
"users": [
{
"bubble_id": "bubble-tegwick-001",
"username": "tegwick",
"status": "Active",
"created": "2025-11-03",
"plan": "flat-899-eur-monthly"
}
]
}

View File

@@ -0,0 +1,11 @@
{
"usage": [
{
"period": "2026-06",
"user_id": "member-tegwick",
"model": "anthropic/claude-3-haiku",
"tokens": 48200,
"cost_usd": "0.06"
}
]
}

View File

@@ -0,0 +1,15 @@
{
"charges": [
{
"id": "pay-2026-06",
"period": "2026-06",
"gross": "8.99",
"fee": "0.44",
"net": "8.55",
"currency": "EUR",
"customer": "tegwick",
"product": "coulomb.social-membership",
"payout_account": "binky-hedgehog"
}
]
}

View File

@@ -0,0 +1,26 @@
{
"version": 1,
"currency": "EUR",
"domains": [
{
"name": "coulomb.social",
"tld": ".social",
"monthly_eur": "3.75",
"icann_fee_annual_eur": "0.14",
"billing_period": "2026-01-31/2027-01-30",
"annual_total_eur": "45.00",
"vat_rate": "19.0"
},
{
"name": "coulomb.pro",
"tld": ".pro",
"monthly_eur": "3.00",
"icann_fee_annual_eur": "0.14",
"billing_period": "2026-01-31/2027-01-30",
"annual_total_eur": "36.00",
"vat_rate": "19.0"
}
],
"monthly_total_eur": "6.75",
"note": "Reference catalog from registrar invoices. Expense ledger rows live in expense_records.json."
}

View File

@@ -0,0 +1,13 @@
{
"version": 1,
"currency": "EUR",
"payout_account": "binky-hedgehog",
"membership": {
"product": "coulomb.social-membership",
"gross_monthly_eur": "8.99",
"fee_monthly_eur": "0.44",
"net_payout_monthly_eur": "8.55",
"member_username": "tegwick"
},
"note": "Reference from actual Stripe payouts. Payment rows live in payment_records.json."
}

View File

@@ -0,0 +1,19 @@
{
"version": 1,
"currency": "EUR",
"servers": [
{
"name": "coulombcore",
"monthly_eur": "13.99",
"started": "2025-01",
"description": "Virtual server hosting for coulombcore"
},
{
"name": "railiance01",
"monthly_eur": "8.99",
"started": "2026-03",
"description": "Virtual server hosting for railiance01"
}
],
"note": "Reference catalog. Monthly expense rows live in expense_records.json."
}

View File

@@ -0,0 +1,56 @@
{
"version": 1,
"currency": "EUR",
"last_reviewed": "2026-06",
"alternatives": [
{
"id": "github-pro",
"name": "GitHub Pro",
"category": "indirect",
"price_monthly_eur": "4.00",
"billing": "monthly",
"features": ["private repos", "protected branches", "CI minutes"],
"signal_level": "S2",
"observed": "2026-06",
"source": "public pricing page",
"note": "Substitute for repository hosting; lacks community and pricing-intelligence positioning."
},
{
"id": "circle-community",
"name": "Circle (community platform)",
"category": "indirect",
"price_monthly_eur": "49.00",
"billing": "monthly",
"features": ["courses", "events", "member spaces", "payments"],
"signal_level": "S2",
"observed": "2026-06",
"source": "public pricing page",
"note": "Higher floor for hosted community; Coulomb is narrower and operator-led."
},
{
"id": "patreon-creator",
"name": "Patreon (creator membership)",
"category": "substitute",
"price_monthly_eur": "8.00",
"billing": "monthly",
"features": ["member posts", "Discord integration", "paywall"],
"signal_level": "S1",
"observed": "2026-06",
"source": "public pricing page",
"note": "Comparable entry price point; different delivery model (content vs repo access)."
},
{
"id": "build-in-house",
"name": "Build in-house community",
"category": "workaround",
"price_monthly_eur": "0.00",
"billing": "operator time",
"features": ["self-hosted forum", "manual onboarding", "no shared pricing lab"],
"signal_level": "S3",
"observed": "2026-06",
"source": "operator estimate",
"note": "Hidden cost in time; common alternative for technical founders."
}
],
"notes": "Market signals are manually curated until competitive intelligence imports exist."
}

View File

@@ -0,0 +1,17 @@
{
"version": 1,
"snapshot_date": "2026-06-22",
"members": [
{
"id": "member-tegwick",
"username": "tegwick",
"external_id": null,
"status": "active",
"joined_at": "2025-11-03",
"plan_id": "flat-899-eur-monthly",
"source": "stripe",
"note": "Sole paying member; coulomb.social membership 8.99 EUR/mo"
}
],
"note": "Infrastructure costs from January 2025; member payments from November 2025."
}

View File

@@ -0,0 +1,118 @@
{
"version": 2,
"records": [
{
"id": "pay-2025-11",
"period": "2025-11",
"gross_amount": "8.99",
"fees_amount": "0.44",
"refunds_amount": "0.00",
"net_amount": "8.55",
"currency": "EUR",
"source": "stripe",
"member_count": 1,
"member_username": "tegwick",
"product": "coulomb.social-membership",
"payout_account": "binky-hedgehog"
},
{
"id": "pay-2025-12",
"period": "2025-12",
"gross_amount": "8.99",
"fees_amount": "0.44",
"refunds_amount": "0.00",
"net_amount": "8.55",
"currency": "EUR",
"source": "stripe",
"member_count": 1,
"member_username": "tegwick",
"product": "coulomb.social-membership",
"payout_account": "binky-hedgehog"
},
{
"id": "pay-2026-01",
"period": "2026-01",
"gross_amount": "8.99",
"fees_amount": "0.44",
"refunds_amount": "0.00",
"net_amount": "8.55",
"currency": "EUR",
"source": "stripe",
"member_count": 1,
"member_username": "tegwick",
"product": "coulomb.social-membership",
"payout_account": "binky-hedgehog"
},
{
"id": "pay-2026-02",
"period": "2026-02",
"gross_amount": "8.99",
"fees_amount": "0.44",
"refunds_amount": "0.00",
"net_amount": "8.55",
"currency": "EUR",
"source": "stripe",
"member_count": 1,
"member_username": "tegwick",
"product": "coulomb.social-membership",
"payout_account": "binky-hedgehog"
},
{
"id": "pay-2026-03",
"period": "2026-03",
"gross_amount": "8.99",
"fees_amount": "0.44",
"refunds_amount": "0.00",
"net_amount": "8.55",
"currency": "EUR",
"source": "stripe",
"member_count": 1,
"member_username": "tegwick",
"product": "coulomb.social-membership",
"payout_account": "binky-hedgehog"
},
{
"id": "pay-2026-04",
"period": "2026-04",
"gross_amount": "8.99",
"fees_amount": "0.44",
"refunds_amount": "0.00",
"net_amount": "8.55",
"currency": "EUR",
"source": "stripe",
"member_count": 1,
"member_username": "tegwick",
"product": "coulomb.social-membership",
"payout_account": "binky-hedgehog"
},
{
"id": "pay-2026-05",
"period": "2026-05",
"gross_amount": "8.99",
"fees_amount": "0.44",
"refunds_amount": "0.00",
"net_amount": "8.55",
"currency": "EUR",
"source": "stripe",
"member_count": 1,
"member_username": "tegwick",
"product": "coulomb.social-membership",
"payout_account": "binky-hedgehog"
},
{
"id": "pay-2026-06",
"period": "2026-06",
"gross_amount": "8.99",
"fees_amount": "0.44",
"refunds_amount": "0.00",
"net_amount": "8.55",
"currency": "EUR",
"source": "stripe",
"member_count": 1,
"member_username": "tegwick",
"product": "coulomb.social-membership",
"payout_account": "binky-hedgehog"
}
],
"note": "Stripe payment ledger for tegwick coulomb.social membership (8.99 EUR gross, 0.44 EUR fees, 8.55 EUR net payout to binky-hedgehog)."
}

View File

@@ -0,0 +1,39 @@
{
"version": 1,
"models": [
{
"id": "flat-899-eur-monthly",
"name": "Standard Membership",
"model_type": "flat_subscription",
"lifecycle_phase": "growth",
"currency": "EUR",
"access_fee_amount": "8.99",
"access_fee_cadence": "monthly",
"included_usage": "unlimited_repository_access",
"status": "active"
},
{
"id": "membership-plus-credits",
"name": "Membership + AI Credits",
"model_type": "hybrid_subscription_usage",
"lifecycle_phase": "exploration",
"currency": "EUR",
"access_fee_amount": "8.99",
"access_fee_cadence": "monthly",
"included_usage": "monthly_ai_credit_allowance",
"status": "candidate"
},
{
"id": "membership-plus-overage",
"name": "Membership + Overage",
"model_type": "hybrid_subscription_usage",
"lifecycle_phase": "exploration",
"currency": "EUR",
"access_fee_amount": "8.99",
"access_fee_cadence": "monthly",
"included_usage": "monthly_ai_credit_allowance",
"overage_meter": "openrouter_tokens",
"status": "candidate"
}
]
}

View File

@@ -0,0 +1,12 @@
{
"id": "coulomb-social-membership",
"name": "Coulomb Social Membership",
"lifecycle_phase": "growth",
"currency": "EUR",
"description": "Repository and community access via monthly subscription.",
"active_pricing_model_id": "flat-899-eur-monthly",
"channels": {
"membership_platform": "bubble.io",
"payments": "stripe"
}
}

View File

@@ -0,0 +1,16 @@
{
"version": 1,
"fx_usd_eur": "0.92",
"records": [
{
"id": "usage-2026-06-tegwick",
"period": "2026-06",
"member_id": "member-tegwick",
"model": "anthropic/claude-3-haiku",
"tokens": 48200,
"cost_usd": "0.06",
"cost_eur": "0.06",
"source": "openrouter"
}
]
}

View File

@@ -0,0 +1,40 @@
{
"version": 1,
"currency": "EUR",
"product_id": "coulomb-social-membership",
"segments": [
{
"id": "solo-builder",
"name": "Solo builder",
"low_eur": "8.99",
"high_eur": "19.00",
"confidence": "hypothesis",
"drivers": ["private repository access", "async community", "low switching cost"],
"evidence": "Founding member joined at list price without negotiation."
},
{
"id": "small-team",
"name": "Small product team",
"low_eur": "12.00",
"high_eur": "29.00",
"confidence": "hypothesis",
"drivers": ["shared norms", "pricing experiment sandbox", "operator proximity"],
"evidence": "No team-tier offer yet; inferred from comparable community memberships."
}
],
"value_drivers": [
{
"id": "repo-access",
"label": "Repository & community access",
"strength": "S2",
"note": "Core deliverable today; differentiated by operator involvement, not feature breadth."
},
{
"id": "pricing-lab",
"label": "Live pricing laboratory",
"strength": "S1",
"note": "Members observe real operator economics; value increases as observatory matures."
}
],
"notes": "Value range is observatory-only until willingness-to-pay interviews and usage signals exist."
}

View File

@@ -0,0 +1,58 @@
# Economic Observatory UI — whynot-design workflow
Build UI from what exists in **whynot-design** (`~/whynot-design` or
`gitea:whynot/whynot-design`). Do not invent parallel components or tokens.
## Before you build
1. **Sync the vendor tree**`make design` (optional `REF=<tag-or-sha>`).
2. **Browse upstream** — inspect `src/elements/` and `src/styles/` in
whynot-design for an existing `<wn-*>` element or `.wn-*` class that fits.
3. **Check the atelier mock** — layout reference only:
https://claude.ai/design/p/fb2eef8c-c1fc-4c75-bff4-3782552e5511
## Layer model
| Layer | Source | Observatory usage |
|-------|--------|-------------------|
| **Layer 1** | `colors_and_type.css`, `components.css`, `tokens/*.json` | Global look; CSS variables (`--paper`, `--sp-*`, `--fg-*`) |
| **Layer 2** | `index.js` + `elements/*.js` (Lit) | App chrome: `wn-top-nav`, `wn-sidebar`, `wn-card`, `wn-field-row`, … |
| **Layer 3** | `ui/styles.css`, `ui/app.js` | Observatory-only layout (`.obs-*`) and data binding |
Layer 3 must consume Layer 1 tokens and Layer 2 components. Avoid gradients,
card shadows, and colours outside the token set.
## Implementation rules
1. **Prefer `<wn-*>` over custom markup** — use `wn-card`, `wn-eyebrow`,
`wn-banner`, `wn-tag`, `wn-field-row`, `wn-table--native` before adding new
patterns.
2. **Extend with `.obs-*`, not `.wn-*`** — never edit vendored CSS/JS; add
layout in `ui/styles.css` only.
3. **Tables** — use `<table class="wn-table--native">` for ledger data; the
shadow-DOM `wn-table` is for Lit-only grids.
4. **Typography** — use `.lead`, `.small`, `.mono` from Layer 1; metric values
use `.obs-metric__value` with `font-variant-numeric: tabular-nums`.
5. **New upstream needs** — add the component or token in whynot-design first,
then `make design` to vendor it here. Do not fork one-off elements into
`ui/`.
## File touch map
| Change | Files |
|--------|-------|
| New section / panel | `ui/index.html`, `ui/app.js`, maybe `ui/styles.css` |
| Chrome or shared widget | whynot-design repo → `make design` |
| Observatory layout only | `ui/styles.css` |
| Data for a panel | `observatory/api.py` + tests |
## Verify
```bash
cd projects/coulomb-pricing
make design # if vendor changed
make test
make serve # http://127.0.0.1:8765/
```
Footer shows pinned ref from `ui/vendor/whynot-design/.whynot-design-ref`.

View File

@@ -0,0 +1,3 @@
"""Coulomb Social Economic Observatory — MVP (ledger, API, importers, simulator)."""
__version__ = "0.1.0"

View File

@@ -0,0 +1,3 @@
from .dashboard import main
raise SystemExit(main())

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from decimal import Decimal
from .models import EconomicsSnapshot
from .usage import build_usage_summary
def build_cost_allocation(
snapshot: EconomicsSnapshot,
usage_records: list[dict],
) -> dict:
usage = build_usage_summary(usage_records, snapshot.period)
variable_ai = usage["total_ai_spend_eur"]
fixed = snapshot.monthly_infrastructure_cost
variable_processing = snapshot.monthly_payment_processing_cost
total = fixed + variable_processing + variable_ai
contribution = snapshot.monthly_revenue - total
return {
"period": snapshot.period,
"currency": snapshot.currency,
"fixed_costs_eur": fixed,
"variable_processing_eur": variable_processing,
"variable_ai_eur": variable_ai,
"total_platform_cost_eur": total,
"cost_floor_eur": snapshot.cost_per_member,
"contribution_margin_eur": contribution,
"contribution_margin_pct": snapshot.gross_margin_pct,
"cost_per_member_eur": snapshot.cost_per_member,
"active_members": snapshot.active_members,
}

View File

@@ -0,0 +1,138 @@
from __future__ import annotations
import json
from decimal import Decimal
from pathlib import Path
from typing import Any
from .economics import build_liquidity_summary, build_snapshot
from .load import (
default_data_dir,
latest_period,
load_budget,
load_expense_records,
load_market_signals,
load_membership,
load_monthly_ledger,
load_payment_records,
load_pricing_models,
load_product,
load_value_range,
)
from .allocation import build_cost_allocation
from .credits import build_credit_summary, load_credit_wallets
from .membership_analytics import build_membership_analytics
from .pricing_context import build_cost_floor, build_market_price_view, build_value_range_view
from .recommendations import build_pricing_recommendations
from .simulator import build_pricing_simulations
from .usage import build_usage_summary, load_usage_records
def _serialize(value: Any) -> Any:
if isinstance(value, Decimal):
return str(value)
if hasattr(value, "__dataclass_fields__"):
return {key: _serialize(getattr(value, key)) for key in value.__dataclass_fields__}
if isinstance(value, list):
return [_serialize(item) for item in value]
if isinstance(value, dict):
return {key: _serialize(item) for key, item in value.items()}
return value
def _load_json_catalog(data_dir: Path, name: str) -> dict:
path = data_dir / "infrastructure" / name
if not path.exists():
return {}
return json.loads(path.read_text(encoding="utf-8"))
def build_dashboard_payload(data_dir: Path | None = None, period: str | None = None) -> dict:
root = data_dir or default_data_dir()
product = load_product(root)
budget = load_budget(root)
models = load_pricing_models(root)
members = load_membership(root)
payments = load_payment_records(root)
expenses = load_expense_records(root)
ledger = load_monthly_ledger(root)
target_period = period or latest_period(ledger)
snapshot = build_snapshot(target_period, product, models, members, payments, ledger)
liquidity = build_liquidity_summary(budget, payments, ledger, target_period)
payment_by_period = {record.period: record for record in payments}
history = []
for month in sorted(ledger, key=lambda row: row.period):
if month.period > target_period:
continue
payment = payment_by_period.get(month.period)
net_payment = payment.net_amount if payment else Decimal("0")
history.append(
{
"period": month.period,
"active_members": month.active_members,
"gross_revenue": month.gross_revenue,
"infrastructure_cost": month.infrastructure_cost,
"payment_processing_cost": month.payment_processing_cost,
"total_platform_cost": month.total_platform_cost,
"net_payment": net_payment,
"net_liquidity": net_payment - month.infrastructure_cost,
}
)
value_range_raw = load_value_range(root)
market_raw = load_market_signals(root)
usage_records = load_usage_records(root)
usage_summary = build_usage_summary(usage_records, target_period)
cost_floor = build_cost_floor(snapshot, models)
value_range = build_value_range_view(value_range_raw, snapshot, product, models)
market_price = build_market_price_view(market_raw)
cost_allocation = build_cost_allocation(snapshot, usage_records)
ai_cost_per_member = usage_summary["cost_per_active_user_eur"]
simulations = build_pricing_simulations(snapshot, models, ai_cost_per_member)
credit_wallets = load_credit_wallets(root)
credit_summary = build_credit_summary(
credit_wallets,
{key: value for key, value in usage_summary["by_member"].items()},
target_period,
)
recommendations = build_pricing_recommendations(
cost_floor, value_range, market_price, simulations, usage_summary
)
return _serialize(
{
"design_reference": "https://claude.ai/design/p/fb2eef8c-c1fc-4c75-bff4-3782552e5511",
"period": target_period,
"product": product,
"budget": budget,
"snapshot": snapshot,
"liquidity": liquidity,
"history": history,
"pricing_models": models,
"members": members,
"payments": payments,
"expense_record_count": len(expenses),
"membership_analytics": build_membership_analytics(
members, target_period, [row["period"] for row in history]
),
"cost_floor": cost_floor,
"value_range": value_range,
"market_price": market_price,
"usage": usage_summary,
"cost_allocation": cost_allocation,
"pricing_simulations": simulations,
"credit_wallets": credit_summary,
"recommendations": recommendations,
"infrastructure": {
"domains": _load_json_catalog(root, "domains.json"),
"virtual_servers": _load_json_catalog(root, "virtual_servers.json"),
"stripe": _load_json_catalog(root, "stripe.json"),
},
}
)
def payload_json(data_dir: Path | None = None, period: str | None = None) -> str:
return json.dumps(build_dashboard_payload(data_dir, period), indent=2)

View File

@@ -0,0 +1,48 @@
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from pathlib import Path
from typing import Any
from .load import _read_json, default_data_dir
TWOPLACES = Decimal("0.01")
def _money(value: Decimal) -> Decimal:
return value.quantize(TWOPLACES, rounding=ROUND_HALF_UP)
def load_credit_wallets(data_dir=None) -> dict[str, Any]:
root = data_dir or default_data_dir()
path = Path(root) / "credit_wallets.json"
if not path.exists():
return {"version": 1, "currency": "EUR", "wallets": []}
return _read_json(path)
def build_credit_summary(raw: dict, usage_by_member: dict[str, Decimal], period: str) -> dict:
wallets = []
for item in raw.get("wallets", []):
member_id = item["member_id"]
allowance = Decimal(str(item.get("monthly_allowance_eur", "0")))
used = usage_by_member.get(member_id, Decimal(str(item.get("used_eur", "0"))))
remaining = max(Decimal("0"), allowance - used)
wallets.append(
{
"member_id": member_id,
"period": period,
"monthly_allowance_eur": _money(allowance),
"used_eur": _money(used),
"remaining_eur": _money(remaining),
"overage_eur": _money(max(Decimal("0"), used - allowance)),
}
)
return {
"period": period,
"currency": raw.get("currency", "EUR"),
"wallet_count": len(wallets),
"wallets": wallets,
"notes": "Observatory-only credit accounting; no customer billing in MVP.",
}

View File

@@ -0,0 +1,170 @@
from __future__ import annotations
import argparse
from decimal import Decimal
from pathlib import Path
from .economics import build_liquidity_summary, build_snapshot
from .load import (
default_data_dir,
latest_period,
load_budget,
load_expense_records,
load_membership,
load_monthly_ledger,
load_payment_records,
load_pricing_models,
load_product,
)
from .models import EconomicsSnapshot, LiquiditySummary, MonthlyPlatformCost, PaymentRecord, PricingModel, Product
def _history_rows(
monthly_costs: list[MonthlyPlatformCost],
payments: list[PaymentRecord],
through_period: str,
) -> str:
payment_by_period = {record.period: record for record in payments}
lines: list[str] = []
for month in sorted(monthly_costs, key=lambda item: item.period):
if month.period > through_period:
continue
payment = payment_by_period.get(month.period)
net_payment = payment.net_amount if payment else Decimal("0")
period_net = net_payment - month.infrastructure_cost
lines.append(
f"| {month.period} | {month.active_members} | {month.gross_revenue} | "
f"{month.infrastructure_cost} | {month.payment_processing_cost} | "
f"{month.total_platform_cost} | {period_net:.2f} |"
)
return "\n".join(lines)
def render_dashboard(
product: Product,
models: list[PricingModel],
snapshot: EconomicsSnapshot,
liquidity: LiquiditySummary,
monthly_costs: list[MonthlyPlatformCost],
payments: list[PaymentRecord],
expense_count: int,
) -> str:
active = next(m for m in models if m.id == product.active_pricing_model_id)
registry_lines = "\n".join(
f"| {model.id} | {model.name} | {model.model_type} | {model.status} |"
for model in models
)
history_lines = _history_rows(monthly_costs, payments, snapshot.period)
budget_state = (
"over budget"
if liquidity.remaining_budget < Decimal("0")
else "within budget"
)
return f"""# Economics Dashboard v1 — {product.name}
**Period:** {snapshot.period}
**Lifecycle phase:** {product.lifecycle_phase}
**Active pricing model:** {active.name} ({active.access_fee_amount} {active.currency}/{active.access_fee_cadence})
> Platform costs accrue to the operator. Customer cost-pass-through billing is
> **not active** in MVP — members pay subscription only. All totals are computed
> programmatically from expense and payment record ledgers ({expense_count} expense
> records).
## Key Metrics (current period)
| Metric | Value |
|--------|------:|
| Active members | {snapshot.active_members} |
| Member payments (gross) | {snapshot.monthly_revenue} {snapshot.currency} |
| Infrastructure cost | {snapshot.monthly_infrastructure_cost} {snapshot.currency} |
| Payment processing cost | {snapshot.monthly_payment_processing_cost} {snapshot.currency} |
| Total platform cost | {snapshot.monthly_total_platform_cost} {snapshot.currency} |
| Platform cost per member | {snapshot.cost_per_member} {snapshot.currency} |
| Period gross margin | {snapshot.gross_margin} {snapshot.currency} |
| Period gross margin % | {snapshot.gross_margin_pct}% |
| Period net liquidity | {snapshot.period_net_liquidity} {snapshot.currency} ({snapshot.liquidity_status}) |
_Period net liquidity = net member payments infrastructure cost (processing fees already netted from payments)._
_Revenue source: {snapshot.revenue_source}_
## Liquidity & Budget (through {liquidity.through_period})
| Metric | Value |
|--------|------:|
| Initial budget | {liquidity.initial_budget} {liquidity.currency} |
| Cumulative member payments (net) | {liquidity.cumulative_member_payments} {liquidity.currency} |
| Cumulative infrastructure cost | {liquidity.cumulative_infrastructure_cost} {liquidity.currency} |
| Cumulative payment processing | {liquidity.cumulative_payment_processing_cost} {liquidity.currency} |
| Cumulative total platform cost | {liquidity.cumulative_total_platform_cost} {liquidity.currency} |
| Cumulative net liquidity | {liquidity.cumulative_net_liquidity} {liquidity.currency} ({liquidity.liquidity_status}) |
| Remaining budget | {liquidity.remaining_budget} {liquidity.currency} ({budget_state}) |
| Months tracked | {liquidity.months_tracked} |
## Monthly History
| Period | Members | Gross revenue | Infrastructure | Processing | Total platform | Net liquidity |
|--------|--------:|--------------:|---------------:|-----------:|---------------:|--------------:|
{history_lines}
## Pricing Model Registry
| ID | Name | Type | Status |
|----|------|------|--------|
{registry_lines}
## Registries Loaded
- Product model (`data/product.json`)
- Budget (`data/budget.json`)
- Expense records (`data/expense_records.json`) — source of truth for costs
- Infrastructure catalog (`data/infrastructure/`) — domain, VPS, and Stripe reference data
- Payment records (`data/payment_records.json`)
- Membership (`data/membership.json`)
- Requirements (`REQUIREMENTS.md`)
"""
def generate_dashboard(data_dir: Path | None = None, period: str | None = None) -> str:
root = data_dir or default_data_dir()
product = load_product(root)
budget = load_budget(root)
models = load_pricing_models(root)
members = load_membership(root)
payments = load_payment_records(root)
expenses = load_expense_records(root)
monthly_costs = load_monthly_ledger(root)
target_period = period or latest_period(monthly_costs)
snapshot = build_snapshot(target_period, product, models, members, payments, monthly_costs)
liquidity = build_liquidity_summary(budget, payments, monthly_costs, target_period)
return render_dashboard(
product, models, snapshot, liquidity, monthly_costs, payments, len(expenses)
)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Coulomb Social Economics Dashboard v1")
parser.add_argument("--data-dir", type=Path, default=None, help="Registry data directory")
parser.add_argument("--period", default=None, help="Reporting period (YYYY-MM)")
parser.add_argument(
"--output",
type=Path,
default=None,
help="Write Markdown report to this path (default: stdout only)",
)
args = parser.parse_args(argv)
report = generate_dashboard(args.data_dir, args.period)
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(report, encoding="utf-8")
print(f"Wrote {args.output}")
else:
print(report)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,155 @@
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from .models import (
Budget,
EconomicsSnapshot,
LiquidityStatus,
LiquiditySummary,
MembershipRecord,
MonthlyPlatformCost,
PaymentRecord,
PricingModel,
Product,
)
TWOPLACES = Decimal("0.01")
PCTPLACES = Decimal("0.1")
def _quantize(value: Decimal, exp: Decimal = TWOPLACES) -> Decimal:
return value.quantize(exp, rounding=ROUND_HALF_UP)
def liquidity_status_for(net: Decimal) -> LiquidityStatus:
if net < Decimal("0"):
return "burning"
if net > Decimal("0"):
return "generating"
return "neutral"
def active_members(members: list[MembershipRecord]) -> int:
return sum(1 for member in members if member.status == "active")
def active_pricing_model(models: list[PricingModel], product: Product) -> PricingModel:
for model in models:
if model.id == product.active_pricing_model_id:
return model
raise ValueError(f"active pricing model not found: {product.active_pricing_model_id}")
def payment_for_period(period: str, payments: list[PaymentRecord]) -> PaymentRecord | None:
for record in payments:
if record.period == period:
return record
return None
def estimate_monthly_revenue(
period: str,
product: Product,
models: list[PricingModel],
members: list[MembershipRecord],
payments: list[PaymentRecord],
monthly_costs: list[MonthlyPlatformCost],
) -> tuple[Decimal, Decimal, str]:
recorded = payment_for_period(period, payments)
if recorded:
return recorded.gross_amount, recorded.net_amount, recorded.source
month = next((item for item in monthly_costs if item.period == period), None)
if month and month.gross_revenue > 0:
return month.gross_revenue, month.gross_revenue, "derived_from_ledger"
model = active_pricing_model(models, product)
count = active_members(members)
gross = model.access_fee_amount * count
return gross, gross, "derived_from_membership"
def periods_through(target: str, monthly_costs: list[MonthlyPlatformCost]) -> list[str]:
return sorted(item.period for item in monthly_costs if item.period <= target)
def build_liquidity_summary(
budget: Budget,
payments: list[PaymentRecord],
monthly_costs: list[MonthlyPlatformCost],
through_period: str,
) -> LiquiditySummary:
tracked = periods_through(through_period, monthly_costs)
cost_by_period = {item.period: item for item in monthly_costs}
cumulative_payments = Decimal("0")
cumulative_infrastructure = Decimal("0")
cumulative_processing = Decimal("0")
for period in tracked:
month = cost_by_period[period]
cumulative_infrastructure += month.infrastructure_cost
cumulative_processing += month.payment_processing_cost
payment = payment_for_period(period, payments)
cumulative_payments += payment.net_amount if payment else Decimal("0")
cumulative_net = _quantize(cumulative_payments - cumulative_infrastructure)
remaining = _quantize(budget.initial_budget + cumulative_net)
cumulative_total = _quantize(cumulative_infrastructure + cumulative_processing)
return LiquiditySummary(
currency=budget.currency,
through_period=through_period,
initial_budget=budget.initial_budget,
cumulative_member_payments=_quantize(cumulative_payments),
cumulative_infrastructure_cost=_quantize(cumulative_infrastructure),
cumulative_payment_processing_cost=_quantize(cumulative_processing),
cumulative_total_platform_cost=cumulative_total,
cumulative_net_liquidity=cumulative_net,
remaining_budget=remaining,
liquidity_status=liquidity_status_for(cumulative_net),
months_tracked=len(tracked),
)
def build_snapshot(
period: str,
product: Product,
models: list[PricingModel],
members: list[MembershipRecord],
payments: list[PaymentRecord],
monthly_costs: list[MonthlyPlatformCost],
) -> EconomicsSnapshot:
month = next(item for item in monthly_costs if item.period == period)
count = month.active_members if month.active_members else active_members(members)
gross_revenue, net_revenue, revenue_source = estimate_monthly_revenue(
period, product, models, members, payments, monthly_costs
)
infrastructure = month.infrastructure_cost
processing = month.payment_processing_cost
total_platform = month.total_platform_cost
cost_per_member = _quantize(total_platform / count) if count else Decimal("0.00")
gross_margin = _quantize(gross_revenue - total_platform)
margin_pct = (
_quantize((gross_margin / gross_revenue) * Decimal("100"), PCTPLACES)
if gross_revenue
else Decimal("-100.0") if total_platform else Decimal("0.0")
)
period_net = _quantize(net_revenue - infrastructure)
return EconomicsSnapshot(
period=period,
currency=product.currency,
active_members=count,
monthly_revenue=_quantize(gross_revenue),
monthly_infrastructure_cost=infrastructure,
monthly_payment_processing_cost=processing,
monthly_total_platform_cost=total_platform,
cost_per_member=cost_per_member,
gross_margin=gross_margin,
gross_margin_pct=margin_pct,
pricing_model_count=len(models),
revenue_source=revenue_source,
period_net_liquidity=period_net,
liquidity_status=liquidity_status_for(period_net),
)

View File

@@ -0,0 +1 @@
"""File-based importers for Bubble, Stripe, and OpenRouter exports."""

View File

@@ -0,0 +1,13 @@
from __future__ import annotations
import json
from pathlib import Path
def read_export(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def write_registry(path: Path, payload: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
import argparse
from pathlib import Path
from . import _io
BUBBLE_STATUS = {"Active": "active", "Cancelled": "churned", "Paused": "paused"}
def import_membership(export: dict, plan_id: str = "flat-899-eur-monthly") -> dict:
members = []
for index, user in enumerate(export.get("users", []), start=1):
bubble_id = user.get("bubble_id") or user.get("_id") or f"bubble-{index}"
username = user.get("username") or user.get("email") or bubble_id
status = BUBBLE_STATUS.get(user.get("status", "Active"), "active")
members.append(
{
"id": f"member-{username}",
"username": username,
"external_id": bubble_id,
"status": status,
"joined_at": user.get("created") or user.get("joined_at"),
"plan_id": user.get("plan") or plan_id,
"source": "bubble",
"churned_at": user.get("cancelled_at") if status == "churned" else None,
}
)
return {
"version": 1,
"snapshot_date": export.get("exported_at", export.get("snapshot_date")),
"members": members,
"note": "Imported from Bubble export",
}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Import Bubble membership export")
parser.add_argument("--input", type=Path, required=True, help="Bubble JSON export")
parser.add_argument(
"--output",
type=Path,
default=Path(__file__).resolve().parent.parent.parent / "data" / "membership.json",
)
parser.add_argument("--plan-id", default="flat-899-eur-monthly")
args = parser.parse_args(argv)
payload = import_membership(_io.read_export(args.input), args.plan_id)
_io.write_registry(args.output, payload)
print(f"Wrote {len(payload['members'])} members → {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,57 @@
from __future__ import annotations
import argparse
from decimal import Decimal
from pathlib import Path
from . import _io
def _money(value: str | int | float) -> str:
return f"{Decimal(str(value)):.2f}"
def import_usage(export: dict, fx_usd_eur: str = "0.92") -> dict:
rate = Decimal(str(fx_usd_eur))
records = []
for index, row in enumerate(export.get("usage", export.get("records", [])), start=1):
cost_usd = Decimal(str(row.get("cost_usd") or row.get("cost", "0")))
cost_eur = (cost_usd * rate).quantize(Decimal("0.01"))
records.append(
{
"id": row.get("id") or f"usage-{row['period']}-{index}",
"period": row["period"],
"member_id": row.get("member_id") or row.get("user_id"),
"model": row["model"],
"tokens": row.get("tokens", 0),
"cost_usd": _money(cost_usd),
"cost_eur": _money(cost_eur),
"source": "openrouter",
}
)
return {
"version": 1,
"fx_usd_eur": str(rate),
"records": sorted(records, key=lambda item: (item["period"], item["id"])),
}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Import OpenRouter usage export")
parser.add_argument("--input", type=Path, required=True, help="OpenRouter JSON export")
parser.add_argument(
"--output",
type=Path,
default=Path(__file__).resolve().parent.parent.parent / "data" / "usage_records.json",
)
parser.add_argument("--fx-usd-eur", default="0.92")
args = parser.parse_args(argv)
payload = import_usage(_io.read_export(args.input), args.fx_usd_eur)
_io.write_registry(args.output, payload)
print(f"Wrote {len(payload['records'])} usage records → {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
import argparse
from decimal import Decimal
from pathlib import Path
from . import _io
def _money(value: str | int | float) -> str:
return f"{Decimal(str(value)):.2f}"
def import_payments(export: dict) -> dict:
records = []
for index, charge in enumerate(export.get("charges", export.get("records", [])), start=1):
period = charge["period"]
records.append(
{
"id": charge.get("id") or f"pay-{period}-{index}",
"period": period,
"gross_amount": _money(charge.get("gross") or charge["gross_amount"]),
"fees_amount": _money(charge.get("fee") or charge.get("fees_amount", "0")),
"refunds_amount": _money(charge.get("refunds_amount", "0")),
"net_amount": _money(charge.get("net") or charge["net_amount"]),
"currency": charge.get("currency", "EUR"),
"source": "stripe",
"member_count": charge.get("member_count", 1),
"member_username": charge.get("customer") or charge.get("member_username"),
"product": charge.get("product"),
"payout_account": charge.get("payout_account"),
}
)
return {"version": 2, "records": sorted(records, key=lambda item: item["period"])}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Import Stripe charge export")
parser.add_argument("--input", type=Path, required=True, help="Stripe JSON export")
parser.add_argument(
"--output",
type=Path,
default=Path(__file__).resolve().parent.parent.parent / "data" / "payment_records.json",
)
args = parser.parse_args(argv)
payload = import_payments(_io.read_export(args.input))
_io.write_registry(args.output, payload)
print(f"Wrote {len(payload['records'])} payment records → {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,121 @@
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from .models import (
Budget,
ExpenseRecord,
MembershipRecord,
MonthlyPlatformCost,
PaymentRecord,
)
TWOPLACES = Decimal("0.01")
def _quantize(value: Decimal) -> Decimal:
return value.quantize(TWOPLACES, rounding=ROUND_HALF_UP)
def convert_to_eur(amount: Decimal, currency: str, fx_rates: dict[str, Decimal]) -> Decimal:
if currency == "EUR":
return amount
if currency == "USD":
return amount * fx_rates.get("USD/EUR", Decimal("1"))
raise ValueError(f"unsupported expense currency: {currency}")
def aggregate_infrastructure_by_period(
expenses: list[ExpenseRecord],
fx_rates: dict[str, Decimal],
reporting_currency: str = "EUR",
) -> dict[str, Decimal]:
if reporting_currency != "EUR":
raise ValueError("only EUR reporting currency is supported")
totals: dict[str, Decimal] = {}
for record in expenses:
if record.cost_class != "infrastructure":
continue
totals[record.period] = totals.get(record.period, Decimal("0")) + convert_to_eur(
record.amount, record.currency, fx_rates
)
return {period: _quantize(total) for period, total in totals.items()}
def payment_processing_by_period(payments: list[PaymentRecord]) -> dict[str, Decimal]:
totals: dict[str, Decimal] = {}
for record in payments:
totals[record.period] = totals.get(record.period, Decimal("0")) + record.fees_amount
return {period: _quantize(total) for period, total in totals.items()}
def _period_sort_key(period: str) -> tuple[int, int]:
year, month = period.split("-")
return int(year), int(month)
def periods_from_budget_through_latest(
budget: Budget,
expenses: list[ExpenseRecord],
payments: list[PaymentRecord],
) -> list[str]:
candidates = {budget.started}
candidates.update(record.period for record in expenses)
candidates.update(record.period for record in payments)
latest = max(candidates, key=_period_sort_key)
periods: list[str] = []
year, month = map(int, budget.started.split("-"))
end_year, end_month = map(int, latest.split("-"))
while (year, month) <= (end_year, end_month):
periods.append(f"{year}-{month:02d}")
month += 1
if month > 12:
month = 1
year += 1
return periods
def active_members_for_period(period: str, members: list[MembershipRecord]) -> int:
period_start = f"{period}-01"
count = 0
for member in members:
if member.joined_at[:7] > period:
continue
if member.churned_at and member.churned_at[:7] < period:
continue
if member.status == "active" or (
member.churned_at and member.churned_at[:7] >= period
):
count += 1
return count
def build_monthly_ledger(
budget: Budget,
expenses: list[ExpenseRecord],
payments: list[PaymentRecord],
members: list[MembershipRecord],
fx_rates: dict[str, Decimal],
) -> list[MonthlyPlatformCost]:
infrastructure = aggregate_infrastructure_by_period(expenses, fx_rates)
processing = payment_processing_by_period(payments)
payment_by_period = {record.period: record for record in payments}
rows: list[MonthlyPlatformCost] = []
for period in periods_from_budget_through_latest(budget, expenses, payments):
payment = payment_by_period.get(period)
rows.append(
MonthlyPlatformCost(
period=period,
infrastructure_cost=infrastructure.get(period, Decimal("0.00")),
payment_processing_cost=processing.get(period, Decimal("0.00")),
active_members=(
payment.member_count
if payment and payment.member_count
else active_members_for_period(period, members)
),
gross_revenue=payment.gross_amount if payment else Decimal("0.00"),
)
)
return rows

View File

@@ -0,0 +1,153 @@
from __future__ import annotations
import json
from decimal import Decimal
from pathlib import Path
from .ledger import build_monthly_ledger
from .models import (
Budget,
ExpenseRecord,
MembershipRecord,
MonthlyPlatformCost,
PaymentRecord,
PricingModel,
Product,
)
def _money(value: str | int | float) -> Decimal:
return Decimal(str(value))
def _read_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def default_data_dir() -> Path:
return Path(__file__).resolve().parent.parent / "data"
def load_product(data_dir: Path | None = None) -> Product:
raw = _read_json((data_dir or default_data_dir()) / "product.json")
return Product(
id=raw["id"],
name=raw["name"],
lifecycle_phase=raw["lifecycle_phase"],
currency=raw["currency"],
description=raw["description"],
active_pricing_model_id=raw["active_pricing_model_id"],
)
def load_budget(data_dir: Path | None = None) -> Budget:
raw = _read_json((data_dir or default_data_dir()) / "budget.json")
return Budget(
currency=raw["currency"],
initial_budget=_money(raw["initial_budget"]),
started=raw["started"],
)
def load_pricing_models(data_dir: Path | None = None) -> list[PricingModel]:
raw = _read_json((data_dir or default_data_dir()) / "pricing-models.json")
return [
PricingModel(
id=item["id"],
name=item["name"],
model_type=item["model_type"],
lifecycle_phase=item["lifecycle_phase"],
currency=item["currency"],
access_fee_amount=_money(item["access_fee_amount"]),
access_fee_cadence=item["access_fee_cadence"],
status=item["status"],
)
for item in raw["models"]
]
def load_fx_rates(data_dir: Path | None = None) -> dict[str, Decimal]:
raw = _read_json((data_dir or default_data_dir()) / "expense_records.json")
return {pair: _money(rate) for pair, rate in raw.get("fx_rates", {}).items()} if raw.get(
"fx_rates"
) else {}
def load_expense_records(data_dir: Path | None = None) -> list[ExpenseRecord]:
raw = _read_json((data_dir or default_data_dir()) / "expense_records.json")
return [
ExpenseRecord(
id=item["id"],
period=item["period"],
vendor=item["vendor"],
description=item["description"],
cost_class=item["cost_class"],
amount=_money(item["amount"]),
currency=item["currency"],
source=item["source"],
)
for item in raw["records"]
]
def load_payment_records(data_dir: Path | None = None) -> list[PaymentRecord]:
root = data_dir or default_data_dir()
path = root / "payment_records.json"
if not path.exists():
# Backward compatibility with legacy revenue.json
path = root / "revenue.json"
raw = _read_json(path)
items = raw.get("records", raw.get("entries", []))
return [
PaymentRecord(
id=item["id"],
period=item["period"],
gross_amount=_money(item["gross_amount"]),
fees_amount=_money(item["fees_amount"]),
refunds_amount=_money(item.get("refunds_amount", "0")),
net_amount=_money(item["net_amount"]),
currency=item["currency"],
source=item["source"],
member_count=item.get("member_count", 0),
member_username=item.get("member_username"),
payout_account=item.get("payout_account"),
)
for item in items
]
def load_value_range(data_dir: Path | None = None) -> dict:
return _read_json((data_dir or default_data_dir()) / "value_range.json")
def load_market_signals(data_dir: Path | None = None) -> dict:
return _read_json((data_dir or default_data_dir()) / "market_signals.json")
def load_membership(data_dir: Path | None = None) -> list[MembershipRecord]:
raw = _read_json((data_dir or default_data_dir()) / "membership.json")
return [
MembershipRecord(
id=item["id"],
status=item["status"],
joined_at=item["joined_at"],
plan_id=item["plan_id"],
churned_at=item.get("churned_at"),
)
for item in raw["members"]
]
def load_monthly_ledger(data_dir: Path | None = None) -> list[MonthlyPlatformCost]:
root = data_dir or default_data_dir()
return build_monthly_ledger(
load_budget(root),
load_expense_records(root),
load_payment_records(root),
load_membership(root),
load_fx_rates(root),
)
def latest_period(monthly_costs: list[MonthlyPlatformCost]) -> str:
return max(item.period for item in monthly_costs)

View File

@@ -0,0 +1,48 @@
from __future__ import annotations
from .models import MembershipRecord
def _period_prefix(date: str | None) -> str | None:
if not date or len(date) < 7:
return None
return date[:7]
def build_membership_analytics(
members: list[MembershipRecord],
period: str,
history: list[str],
) -> dict:
active = sum(1 for member in members if member.status == "active")
new_members = sum(1 for member in members if _period_prefix(member.joined_at) == period)
churned = sum(1 for member in members if _period_prefix(member.churned_at) == period)
prior_period = None
sorted_periods = sorted(history)
if period in sorted_periods:
index = sorted_periods.index(period)
if index > 0:
prior_period = sorted_periods[index - 1]
prior_active = active
if prior_period:
prior_active = sum(
1
for member in members
if member.status == "active" and _period_prefix(member.joined_at) <= prior_period
)
growth_rate = None
if prior_active:
growth_rate = round(((active - prior_active) / prior_active) * 100, 1)
return {
"period": period,
"total_members": len(members),
"active_members": active,
"new_members": new_members,
"churned_members": churned,
"growth_rate_pct": growth_rate,
"snapshot_source": "membership.json",
}

View File

@@ -0,0 +1,121 @@
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from typing import Literal
ExpenseClass = Literal["infrastructure", "payment_processing"]
MemberStatus = Literal["active", "churned", "paused"]
PricingModelStatus = Literal["active", "candidate", "retired"]
LiquidityStatus = Literal["burning", "neutral", "generating"]
@dataclass(frozen=True)
class Product:
id: str
name: str
lifecycle_phase: str
currency: str
description: str
active_pricing_model_id: str
@dataclass(frozen=True)
class PricingModel:
id: str
name: str
model_type: str
lifecycle_phase: str
currency: str
access_fee_amount: Decimal
access_fee_cadence: str
status: PricingModelStatus
@dataclass(frozen=True)
class ExpenseRecord:
id: str
period: str
vendor: str
description: str
cost_class: ExpenseClass
amount: Decimal
currency: str
source: str
@dataclass(frozen=True)
class PaymentRecord:
id: str
period: str
gross_amount: Decimal
fees_amount: Decimal
refunds_amount: Decimal
net_amount: Decimal
currency: str
source: str
member_count: int = 0
member_username: str | None = None
payout_account: str | None = None
@dataclass(frozen=True)
class MonthlyPlatformCost:
period: str
infrastructure_cost: Decimal
payment_processing_cost: Decimal
active_members: int
gross_revenue: Decimal
@property
def total_platform_cost(self) -> Decimal:
return self.infrastructure_cost + self.payment_processing_cost
@dataclass(frozen=True)
class Budget:
currency: str
initial_budget: Decimal
started: str
@dataclass(frozen=True)
class MembershipRecord:
id: str
status: MemberStatus
joined_at: str
plan_id: str
churned_at: str | None = None
@dataclass(frozen=True)
class EconomicsSnapshot:
period: str
currency: str
active_members: int
monthly_revenue: Decimal
monthly_infrastructure_cost: Decimal
monthly_payment_processing_cost: Decimal
monthly_total_platform_cost: Decimal
cost_per_member: Decimal
gross_margin: Decimal
gross_margin_pct: Decimal
pricing_model_count: int
revenue_source: str
period_net_liquidity: Decimal
liquidity_status: LiquidityStatus
@dataclass(frozen=True)
class LiquiditySummary:
currency: str
through_period: str
initial_budget: Decimal
cumulative_member_payments: Decimal
cumulative_infrastructure_cost: Decimal
cumulative_payment_processing_cost: Decimal
cumulative_total_platform_cost: Decimal
cumulative_net_liquidity: Decimal
remaining_budget: Decimal
liquidity_status: LiquidityStatus
months_tracked: int

View File

@@ -0,0 +1,93 @@
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from .economics import active_pricing_model
from .models import EconomicsSnapshot, PricingModel, Product
TWOPLACES = Decimal("0.01")
def _quantize(value: Decimal) -> Decimal:
return value.quantize(TWOPLACES, rounding=ROUND_HALF_UP)
def build_cost_floor(
snapshot: EconomicsSnapshot,
models: list[PricingModel],
) -> dict:
active = next((m for m in models if m.status == "active"), None)
return {
"period": snapshot.period,
"currency": snapshot.currency,
"monthly_infrastructure_cost": snapshot.monthly_infrastructure_cost,
"monthly_payment_processing_cost": snapshot.monthly_payment_processing_cost,
"monthly_total_platform_cost": snapshot.monthly_total_platform_cost,
"cost_per_member": snapshot.cost_per_member,
"active_members": snapshot.active_members,
"monthly_revenue": snapshot.monthly_revenue,
"gross_margin": snapshot.gross_margin,
"gross_margin_pct": snapshot.gross_margin_pct,
"active_price": active.access_fee_amount if active else Decimal("0"),
"active_model_id": active.id if active else None,
"active_model_name": active.name if active else None,
}
def build_value_range_view(
raw: dict,
snapshot: EconomicsSnapshot,
product: Product,
models: list[PricingModel],
) -> dict:
model = active_pricing_model(models, product)
current = model.access_fee_amount
segments = []
lows: list[Decimal] = []
highs: list[Decimal] = []
for item in raw.get("segments", []):
low = Decimal(str(item["low_eur"]))
high = Decimal(str(item["high_eur"]))
lows.append(low)
highs.append(high)
segments.append(
{
**item,
"headroom_to_high_eur": _quantize(high - current),
"below_floor": current < low,
}
)
aggregate_low = min(lows) if lows else current
aggregate_high = max(highs) if highs else current
return {
"currency": raw.get("currency", snapshot.currency),
"product_id": raw.get("product_id", product.id),
"current_price_eur": current,
"aggregate_low_eur": aggregate_low,
"aggregate_high_eur": aggregate_high,
"cost_per_member_eur": snapshot.cost_per_member,
"segments": segments,
"value_drivers": raw.get("value_drivers", []),
"notes": raw.get("notes", ""),
}
def build_market_price_view(raw: dict) -> dict:
alternatives = list(raw.get("alternatives", []))
prices = [
Decimal(str(item["price_monthly_eur"]))
for item in alternatives
if item.get("price_monthly_eur") not in (None, "", "0", "0.00")
]
return {
"currency": raw.get("currency", "EUR"),
"last_reviewed": raw.get("last_reviewed"),
"alternative_count": len(alternatives),
"priced_alternative_count": len(prices),
"market_low_eur": min(prices) if prices else None,
"market_high_eur": max(prices) if prices else None,
"alternatives": alternatives,
"notes": raw.get("notes", ""),
}

View File

@@ -0,0 +1,68 @@
from __future__ import annotations
from decimal import Decimal
def build_pricing_recommendations(
cost_floor: dict,
value_range: dict,
market_price: dict,
simulations: dict,
usage_summary: dict,
) -> list[dict]:
recommendations: list[dict] = []
margin_pct = Decimal(str(cost_floor.get("gross_margin_pct", "0")))
ai_spend = Decimal(str(usage_summary.get("total_ai_spend_eur", "0")))
active_price = Decimal(str(value_range.get("current_price_eur", "0")))
cost_per_member = Decimal(str(cost_floor.get("cost_per_member", "0")))
if margin_pct < Decimal("10"):
recommendations.append(
{
"id": "margin-pressure",
"priority": "high",
"title": "Margin below 10%",
"rationale": f"Gross margin is {margin_pct}% at current pricing.",
"suggested_action": "Review infrastructure cost or test a higher access fee within value-range bands.",
}
)
if ai_spend > Decimal("0") and cost_per_member > Decimal("0"):
ai_ratio = (ai_spend / cost_per_member) * Decimal("100")
if ai_ratio > Decimal("15"):
best = simulations.get("best_margin_scenario_id")
recommendations.append(
{
"id": "usage-pricing-signal",
"priority": "medium",
"title": "AI cost becoming material",
"rationale": f"AI spend is {ai_ratio:.1f}% of cost-per-member this period.",
"suggested_action": f"Evaluate hybrid model '{best}' in the pricing simulator before customer-visible credits.",
}
)
if market_price.get("market_high_eur") and active_price < Decimal(str(market_price["market_high_eur"])):
headroom = Decimal(str(value_range.get("aggregate_high_eur", active_price))) - active_price
if headroom > Decimal("5"):
recommendations.append(
{
"id": "value-headroom",
"priority": "low",
"title": "Value headroom above list price",
"rationale": f"Aggregate value band high is €{value_range['aggregate_high_eur']} vs €{active_price} list.",
"suggested_action": "Run a staged price experiment within the solo-builder segment band.",
}
)
if not recommendations:
recommendations.append(
{
"id": "hold-course",
"priority": "low",
"title": "Hold current pricing",
"rationale": "No urgent margin, usage, or competitive signals detected.",
"suggested_action": "Continue observatory tracking; re-run after next ledger period.",
}
)
return recommendations

View File

@@ -0,0 +1,81 @@
from __future__ import annotations
import argparse
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from .api import build_dashboard_payload
ROOT = Path(__file__).resolve().parent.parent
UI_DIR = ROOT / "ui"
UI_CONTENT_TYPES = {
".css": "text/css",
".js": "application/javascript",
".json": "application/json",
".html": "text/html; charset=utf-8",
}
class ObservatoryHandler(BaseHTTPRequestHandler):
data_dir: Path = ROOT / "data"
def _send(self, status: int, body: bytes, content_type: str) -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None:
parsed = urlparse(self.path)
if parsed.path == "/api/dashboard":
query = parse_qs(parsed.query)
period = query.get("period", [None])[0]
payload = build_dashboard_payload(self.data_dir, period)
self._send(200, json.dumps(payload).encode("utf-8"), "application/json")
return
if parsed.path == "/":
return self._serve_file(UI_DIR / "index.html", "text/html; charset=utf-8")
if parsed.path.startswith("/ui/"):
relative = parsed.path.removeprefix("/ui/")
target = UI_DIR / relative
if target.exists() and target.is_file():
content_type = UI_CONTENT_TYPES.get(target.suffix, "application/octet-stream")
if "charset" not in content_type:
content_type = f"{content_type}; charset=utf-8"
return self._serve_file(target, content_type)
self._send(404, b"Not found", "text/plain")
def _serve_file(self, path: Path, content_type: str) -> None:
self._send(200, path.read_bytes(), content_type)
def log_message(self, format: str, *args) -> None:
return
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Coulomb Economic Observatory UI server")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8765)
parser.add_argument("--data-dir", type=Path, default=ROOT / "data")
args = parser.parse_args(argv)
ObservatoryHandler.data_dir = args.data_dir
server = ThreadingHTTPServer((args.host, args.port), ObservatoryHandler)
print(f"Economic Observatory UI: http://{args.host}:{args.port}/")
print(f"API: http://{args.host}:{args.port}/api/dashboard")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nStopped.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from .models import EconomicsSnapshot, PricingModel
TWOPLACES = Decimal("0.01")
OVERAGE_RATE = Decimal("0.002") # EUR per token above allowance (observatory estimate)
def _money(value: Decimal) -> Decimal:
return value.quantize(TWOPLACES, rounding=ROUND_HALF_UP)
def _simulate_model(
model: PricingModel,
snapshot: EconomicsSnapshot,
ai_cost_per_member: Decimal,
included_tokens: int = 100_000,
actual_tokens: int = 120_000,
) -> dict:
members = snapshot.active_members or 1
subscription_revenue = model.access_fee_amount * members
overage_revenue = Decimal("0")
if model.model_type == "hybrid_subscription_usage" and actual_tokens > included_tokens:
overage_tokens = actual_tokens - included_tokens
overage_revenue = OVERAGE_RATE * overage_tokens * members
gross_revenue = subscription_revenue + overage_revenue
platform_cost = snapshot.monthly_total_platform_cost + (ai_cost_per_member * members)
margin = gross_revenue - platform_cost
margin_pct = (margin / gross_revenue * Decimal("100")) if gross_revenue else Decimal("0")
return {
"model_id": model.id,
"model_name": model.name,
"model_type": model.model_type,
"status": model.status,
"access_fee_eur": model.access_fee_amount,
"projected_revenue_eur": _money(gross_revenue),
"projected_overage_eur": _money(overage_revenue),
"projected_platform_cost_eur": _money(platform_cost),
"projected_margin_eur": _money(margin),
"projected_margin_pct": _money(margin_pct),
"assumed_tokens_per_member": actual_tokens,
"included_tokens": included_tokens if model.model_type != "flat_subscription" else None,
}
def build_pricing_simulations(
snapshot: EconomicsSnapshot,
models: list[PricingModel],
ai_cost_per_member: Decimal,
) -> dict:
scenarios = [
_simulate_model(model, snapshot, ai_cost_per_member)
for model in models
if model.status in ("active", "candidate")
]
active = next((item for item in scenarios if item["status"] == "active"), scenarios[0])
best_margin = max(scenarios, key=lambda item: item["projected_margin_eur"])
return {
"period": snapshot.period,
"currency": snapshot.currency,
"active_scenario_id": active["model_id"],
"best_margin_scenario_id": best_margin["model_id"],
"scenarios": scenarios,
"notes": "Projections hold member count and infrastructure cost constant; overage uses observatory token estimate.",
}

View File

@@ -0,0 +1,48 @@
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from typing import Any
TWOPLACES = Decimal("0.01")
def _money(value: Decimal) -> Decimal:
return value.quantize(TWOPLACES, rounding=ROUND_HALF_UP)
def load_usage_records(data_dir) -> list[dict[str, Any]]:
from pathlib import Path
from .load import _read_json, default_data_dir
root = data_dir or default_data_dir()
path = Path(root) / "usage_records.json"
if not path.exists():
return []
raw = _read_json(path)
return list(raw.get("records", []))
def build_usage_summary(records: list[dict[str, Any]], period: str) -> dict:
period_rows = [row for row in records if row.get("period") == period]
by_member: dict[str, Decimal] = {}
by_model: dict[str, Decimal] = {}
total = Decimal("0")
for row in period_rows:
cost = Decimal(str(row.get("cost_eur", "0")))
total += cost
member_id = row.get("member_id") or "unknown"
model = row.get("model") or "unknown"
by_member[member_id] = by_member.get(member_id, Decimal("0")) + cost
by_model[model] = by_model.get(model, Decimal("0")) + cost
active_members = len(by_member) or 1
return {
"period": period,
"total_ai_spend_eur": _money(total),
"cost_per_active_user_eur": _money(total / active_members),
"by_member": {key: _money(value) for key, value in sorted(by_member.items())},
"by_model": {key: _money(value) for key, value in sorted(by_model.items())},
"record_count": len(period_rows),
}

View File

@@ -0,0 +1,81 @@
# Economics Dashboard v1 — Coulomb Social Membership
**Period:** 2026-06
**Lifecycle phase:** growth
**Active pricing model:** Standard Membership (8.99 EUR/monthly)
> Platform costs accrue to the operator. Customer cost-pass-through billing is
> **not active** in MVP — members pay subscription only. All totals are computed
> programmatically from expense and payment record ledgers (58 expense
> records).
## Key Metrics (current period)
| Metric | Value |
|--------|------:|
| Active members | 1 |
| Member payments (gross) | 8.99 EUR |
| Infrastructure cost | 29.73 EUR |
| Payment processing cost | 0.44 EUR |
| Total platform cost | 30.17 EUR |
| Platform cost per member | 30.17 EUR |
| Period gross margin | -21.18 EUR |
| Period gross margin % | -235.6% |
| Period net liquidity | -21.18 EUR (burning) |
_Period net liquidity = net member payments infrastructure cost (processing fees already netted from payments)._
_Revenue source: stripe_
## Liquidity & Budget (through 2026-06)
| Metric | Value |
|--------|------:|
| Initial budget | 1000.00 EUR |
| Cumulative member payments (net) | 68.40 EUR |
| Cumulative infrastructure cost | 409.28 EUR |
| Cumulative payment processing | 3.52 EUR |
| Cumulative total platform cost | 412.80 EUR |
| Cumulative net liquidity | -340.88 EUR (burning) |
| Remaining budget | 659.12 EUR (within budget) |
| Months tracked | 18 |
## Monthly History
| Period | Members | Gross revenue | Infrastructure | Processing | Total platform | Net liquidity |
|--------|--------:|--------------:|---------------:|-----------:|---------------:|--------------:|
| 2025-01 | 0 | 0.00 | 20.74 | 0.00 | 20.74 | -20.74 |
| 2025-02 | 0 | 0.00 | 20.74 | 0.00 | 20.74 | -20.74 |
| 2025-03 | 0 | 0.00 | 20.74 | 0.00 | 20.74 | -20.74 |
| 2025-04 | 0 | 0.00 | 20.74 | 0.00 | 20.74 | -20.74 |
| 2025-05 | 0 | 0.00 | 20.74 | 0.00 | 20.74 | -20.74 |
| 2025-06 | 0 | 0.00 | 20.74 | 0.00 | 20.74 | -20.74 |
| 2025-07 | 0 | 0.00 | 20.74 | 0.00 | 20.74 | -20.74 |
| 2025-08 | 0 | 0.00 | 20.74 | 0.00 | 20.74 | -20.74 |
| 2025-09 | 0 | 0.00 | 20.74 | 0.00 | 20.74 | -20.74 |
| 2025-10 | 0 | 0.00 | 20.74 | 0.00 | 20.74 | -20.74 |
| 2025-11 | 1 | 8.99 | 20.74 | 0.44 | 21.18 | -12.19 |
| 2025-12 | 1 | 8.99 | 20.74 | 0.44 | 21.18 | -12.19 |
| 2026-01 | 1 | 8.99 | 20.74 | 0.44 | 21.18 | -12.19 |
| 2026-02 | 1 | 8.99 | 20.74 | 0.44 | 21.18 | -12.19 |
| 2026-03 | 1 | 8.99 | 29.73 | 0.44 | 30.17 | -21.18 |
| 2026-04 | 1 | 8.99 | 29.73 | 0.44 | 30.17 | -21.18 |
| 2026-05 | 1 | 8.99 | 29.73 | 0.44 | 30.17 | -21.18 |
| 2026-06 | 1 | 8.99 | 29.73 | 0.44 | 30.17 | -21.18 |
## Pricing Model Registry
| ID | Name | Type | Status |
|----|------|------|--------|
| flat-899-eur-monthly | Standard Membership | flat_subscription | active |
| membership-plus-credits | Membership + AI Credits | hybrid_subscription_usage | candidate |
| membership-plus-overage | Membership + Overage | hybrid_subscription_usage | candidate |
## Registries Loaded
- Product model (`data/product.json`)
- Budget (`data/budget.json`)
- Expense records (`data/expense_records.json`) — source of truth for costs
- Infrastructure catalog (`data/infrastructure/`) — domain, VPS, and Stripe reference data
- Payment records (`data/payment_records.json`)
- Membership (`data/membership.json`)
- Requirements (`REQUIREMENTS.md`)

View File

@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Synchronises the vendored copy of whynot-design from a pinned upstream commit.
# Source: ~/whynot-design (worktree) or a clone from gitea.
#
# Usage: ./scripts/sync-whynot-design.sh [<commit-or-ref>]
# Default: reads .whynot-design-ref from the vendor directory, else HEAD.
#
# Pulls Layer 1 (tokens + CSS) and Layer 2 (Lit web components) so the
# observatory UI picks up design-system changes on re-run.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VENDOR_DIR="$ROOT/ui/vendor/whynot-design"
REF_FILE="$VENDOR_DIR/.whynot-design-ref"
SRC_REPO="${WHYNOT_DESIGN_SRC:-$HOME/whynot-design}"
REF="${1:-}"
if [[ -z "$REF" && -f "$REF_FILE" ]]; then
REF="$(cat "$REF_FILE")"
fi
if [[ -z "$REF" ]]; then
REF="HEAD"
fi
if [[ ! -d "$SRC_REPO/.git" ]]; then
echo "Source not found: $SRC_REPO" >&2
echo "Set WHYNOT_DESIGN_SRC or clone gitea:whynot/whynot-design there." >&2
exit 1
fi
mkdir -p "$VENDOR_DIR/tokens" "$VENDOR_DIR/elements"
git -C "$SRC_REPO" show "$REF:src/styles/colors_and_type.css" > "$VENDOR_DIR/colors_and_type.css"
git -C "$SRC_REPO" show "$REF:src/styles/components.css" > "$VENDOR_DIR/components.css"
git -C "$SRC_REPO" show "$REF:src/index.js" > "$VENDOR_DIR/index.js"
for f in atoms.js chrome.js form.js icons.js layout.js _styles.js; do
git -C "$SRC_REPO" show "$REF:src/elements/$f" > "$VENDOR_DIR/elements/$f"
done
for f in colors.json type.json spacing.json index.json; do
git -C "$SRC_REPO" show "$REF:tokens/$f" > "$VENDOR_DIR/tokens/$f"
done
git -C "$SRC_REPO" rev-parse "$REF" > "$REF_FILE"
echo "Vendor synced → $VENDOR_DIR (ref: $(cat "$REF_FILE"))"

View File

@@ -0,0 +1,6 @@
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
import json
from decimal import Decimal
from pathlib import Path
from observatory.api import build_dashboard_payload, payload_json
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
def test_dashboard_payload_contains_live_ledger_totals() -> None:
payload = build_dashboard_payload(DATA_DIR, "2026-06")
assert payload["period"] == "2026-06"
assert payload["liquidity"]["remaining_budget"] == "659.12"
assert payload["liquidity"]["cumulative_infrastructure_cost"] == "409.28"
assert payload["snapshot"]["monthly_infrastructure_cost"] == "29.73"
assert len(payload["history"]) == 18
assert payload["expense_record_count"] == 58
assert payload["cost_floor"]["active_price"] == "8.99"
assert len(payload["value_range"]["segments"]) == 2
assert payload["market_price"]["alternative_count"] == 4
assert payload["membership_analytics"]["active_members"] == 1
assert payload["usage"]["record_count"] == 1
assert len(payload["pricing_simulations"]["scenarios"]) == 3
assert payload["recommendations"]
def test_payload_json_is_valid() -> None:
parsed = json.loads(payload_json(DATA_DIR, "2026-06"))
assert Decimal(parsed["payments"][0]["fees_amount"]) == Decimal("0.44")

View File

@@ -0,0 +1,117 @@
from __future__ import annotations
from decimal import Decimal
from pathlib import Path
from observatory.economics import active_members, build_liquidity_summary, build_snapshot
from observatory.ledger import aggregate_infrastructure_by_period, build_monthly_ledger
from observatory.load import (
load_budget,
load_expense_records,
load_fx_rates,
load_membership,
load_monthly_ledger,
load_payment_records,
load_pricing_models,
load_product,
)
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
def test_active_members_counts_only_active_status() -> None:
members = load_membership(DATA_DIR)
assert active_members(members) == 1
def test_infrastructure_aggregated_from_domain_expense_records() -> None:
expenses = load_expense_records(DATA_DIR)
fx = load_fx_rates(DATA_DIR)
totals = aggregate_infrastructure_by_period(expenses, fx)
assert totals["2025-01"] == Decimal("20.74")
assert totals["2026-02"] == Decimal("20.74")
assert totals["2026-03"] == Decimal("29.73")
assert totals["2026-06"] == Decimal("29.73")
def test_monthly_ledger_starts_january_2025() -> None:
ledger = load_monthly_ledger(DATA_DIR)
assert ledger[0].period == "2025-01"
assert len(ledger) == 18
march = next(row for row in ledger if row.period == "2025-03")
june = next(row for row in ledger if row.period == "2026-06")
assert march.infrastructure_cost == Decimal("20.74")
assert march.payment_processing_cost == Decimal("0.00")
assert june.infrastructure_cost == Decimal("29.73")
assert june.payment_processing_cost == Decimal("0.44")
assert june.gross_revenue == Decimal("8.99")
def test_build_snapshot_june_2026_domain_only_infrastructure() -> None:
product = load_product(DATA_DIR)
models = load_pricing_models(DATA_DIR)
members = load_membership(DATA_DIR)
payments = load_payment_records(DATA_DIR)
ledger = load_monthly_ledger(DATA_DIR)
snapshot = build_snapshot("2026-06", product, models, members, payments, ledger)
assert snapshot.monthly_infrastructure_cost == Decimal("29.73")
assert snapshot.monthly_payment_processing_cost == Decimal("0.44")
assert snapshot.monthly_total_platform_cost == Decimal("30.17")
assert snapshot.period_net_liquidity == Decimal("-21.18")
assert snapshot.liquidity_status == "burning"
def test_liquidity_summary_with_actual_domain_costs() -> None:
budget = load_budget(DATA_DIR)
payments = load_payment_records(DATA_DIR)
ledger = load_monthly_ledger(DATA_DIR)
summary = build_liquidity_summary(budget, payments, ledger, "2026-06")
assert summary.cumulative_infrastructure_cost == Decimal("409.28")
assert summary.cumulative_payment_processing_cost == Decimal("3.52")
assert summary.cumulative_total_platform_cost == Decimal("412.80")
assert summary.cumulative_member_payments == Decimal("68.40")
assert summary.cumulative_net_liquidity == Decimal("-340.88")
assert summary.remaining_budget == Decimal("659.12")
assert summary.liquidity_status == "burning"
assert summary.months_tracked == 18
def test_build_monthly_ledger_matches_loader() -> None:
budget = load_budget(DATA_DIR)
expenses = load_expense_records(DATA_DIR)
payments = load_payment_records(DATA_DIR)
members = load_membership(DATA_DIR)
fx = load_fx_rates(DATA_DIR)
assert build_monthly_ledger(budget, expenses, payments, members, fx) == load_monthly_ledger(
DATA_DIR
)
def test_payment_records_match_stripe_actuals_for_tegwick() -> None:
payments = load_payment_records(DATA_DIR)
nov = next(p for p in payments if p.period == "2025-11")
assert nov.gross_amount == Decimal("8.99")
assert nov.fees_amount == Decimal("0.44")
assert nov.net_amount == Decimal("8.55")
assert nov.member_username == "tegwick"
assert nov.payout_account == "binky-hedgehog"
assert nov.source == "stripe"
def test_dashboard_notes_expense_record_source() -> None:
from observatory.dashboard import generate_dashboard
report = generate_dashboard(DATA_DIR, "2026-06")
assert "expense and payment record ledgers" in report
assert "409.28" in report
assert "659.12" in report
assert "coulomb.social" not in report # dashboard shows aggregates, not domain names

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from pathlib import Path
from observatory.importers.bubble import import_membership
from observatory.importers.openrouter import import_usage
from observatory.importers.stripe import import_payments
IMPORTS = Path(__file__).resolve().parent.parent / "data" / "imports"
def test_bubble_import_maps_active_member() -> None:
import json
export = json.loads((IMPORTS / "bubble-export.sample.json").read_text(encoding="utf-8"))
payload = import_membership(export)
assert len(payload["members"]) == 1
assert payload["members"][0]["id"] == "member-tegwick"
assert payload["members"][0]["status"] == "active"
assert payload["members"][0]["source"] == "bubble"
def test_stripe_import_normalises_charge() -> None:
import json
export = json.loads((IMPORTS / "stripe-export.sample.json").read_text(encoding="utf-8"))
payload = import_payments(export)
assert payload["records"][0]["gross_amount"] == "8.99"
assert payload["records"][0]["net_amount"] == "8.55"
assert payload["records"][0]["member_username"] == "tegwick"
def test_openrouter_import_converts_cost_to_eur() -> None:
import json
export = json.loads((IMPORTS / "openrouter-export.sample.json").read_text(encoding="utf-8"))
payload = import_usage(export, fx_usd_eur="0.92")
assert payload["records"][0]["cost_eur"] == "0.06"
assert payload["records"][0]["member_id"] == "member-tegwick"

View File

@@ -0,0 +1,96 @@
from __future__ import annotations
from decimal import Decimal
from pathlib import Path
from observatory.allocation import build_cost_allocation
from observatory.api import build_dashboard_payload
from observatory.credits import build_credit_summary, load_credit_wallets
from observatory.economics import build_snapshot
from observatory.load import (
load_membership,
load_monthly_ledger,
load_payment_records,
load_pricing_models,
load_product,
)
from observatory.membership_analytics import build_membership_analytics
from observatory.recommendations import build_pricing_recommendations
from observatory.simulator import build_pricing_simulations
from observatory.usage import build_usage_summary, load_usage_records
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
def _snapshot(period: str = "2026-06"):
product = load_product(DATA_DIR)
models = load_pricing_models(DATA_DIR)
members = load_membership(DATA_DIR)
payments = load_payment_records(DATA_DIR)
ledger = load_monthly_ledger(DATA_DIR)
return build_snapshot(period, product, models, members, payments, ledger)
def test_membership_analytics_counts_active_member() -> None:
members = load_membership(DATA_DIR)
analytics = build_membership_analytics(members, "2026-06", ["2026-05", "2026-06"])
assert analytics["active_members"] == 1
assert analytics["total_members"] == 1
def test_usage_summary_attributes_member_cost() -> None:
records = load_usage_records(DATA_DIR)
summary = build_usage_summary(records, "2026-06")
assert summary["record_count"] == 1
assert summary["by_member"]["member-tegwick"] == Decimal("0.06")
def test_cost_allocation_includes_ai_variable_cost() -> None:
snapshot = _snapshot()
allocation = build_cost_allocation(snapshot, load_usage_records(DATA_DIR))
assert allocation["variable_ai_eur"] == Decimal("0.06")
assert allocation["cost_floor_eur"] == snapshot.cost_per_member
def test_pricing_simulator_compares_candidate_models() -> None:
snapshot = _snapshot()
models = load_pricing_models(DATA_DIR)
simulations = build_pricing_simulations(snapshot, models, Decimal("0.06"))
assert len(simulations["scenarios"]) == 3
assert simulations["active_scenario_id"] == "flat-899-eur-monthly"
def test_credit_summary_tracks_remaining_allowance() -> None:
wallets = load_credit_wallets(DATA_DIR)
summary = build_credit_summary(wallets, {"member-tegwick": Decimal("0.06")}, "2026-06")
assert summary["wallets"][0]["remaining_eur"] == Decimal("1.94")
def test_recommendations_include_hold_or_action() -> None:
payload = build_dashboard_payload(DATA_DIR, "2026-06")
recs = build_pricing_recommendations(
payload["cost_floor"],
payload["value_range"],
payload["market_price"],
payload["pricing_simulations"],
payload["usage"],
)
assert recs
assert recs[0]["id"] in {"margin-pressure", "usage-pricing-signal", "value-headroom", "hold-course"}
def test_dashboard_payload_includes_mvp_sections() -> None:
payload = build_dashboard_payload(DATA_DIR, "2026-06")
assert "membership_analytics" in payload
assert "usage" in payload
assert "cost_allocation" in payload
assert "pricing_simulations" in payload
assert "credit_wallets" in payload
assert "recommendations" in payload

View File

@@ -0,0 +1,61 @@
from __future__ import annotations
from decimal import Decimal
from pathlib import Path
from observatory.economics import build_snapshot
from observatory.load import (
load_market_signals,
load_membership,
load_monthly_ledger,
load_payment_records,
load_pricing_models,
load_product,
load_value_range,
)
from observatory.pricing_context import (
build_cost_floor,
build_market_price_view,
build_value_range_view,
)
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
def _snapshot_for(period: str = "2026-06"):
product = load_product(DATA_DIR)
models = load_pricing_models(DATA_DIR)
members = load_membership(DATA_DIR)
payments = load_payment_records(DATA_DIR)
ledger = load_monthly_ledger(DATA_DIR)
return build_snapshot(period, product, models, members, payments, ledger)
def test_cost_floor_derives_from_snapshot() -> None:
snapshot = _snapshot_for()
models = load_pricing_models(DATA_DIR)
floor = build_cost_floor(snapshot, models)
assert floor["cost_per_member"] == snapshot.cost_per_member
assert floor["active_price"] == Decimal("8.99")
assert floor["active_model_id"] == "flat-899-eur-monthly"
def test_value_range_computes_headroom() -> None:
snapshot = _snapshot_for()
product = load_product(DATA_DIR)
models = load_pricing_models(DATA_DIR)
view = build_value_range_view(load_value_range(DATA_DIR), snapshot, product, models)
assert view["current_price_eur"] == Decimal("8.99")
assert len(view["segments"]) == 2
solo = next(item for item in view["segments"] if item["id"] == "solo-builder")
assert solo["headroom_to_high_eur"] == Decimal("10.01")
def test_market_price_summarises_alternatives() -> None:
view = build_market_price_view(load_market_signals(DATA_DIR))
assert view["alternative_count"] == 4
assert view["market_low_eur"] == Decimal("4.00")
assert view["market_high_eur"] == Decimal("49.00")

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
import importlib
import threading
from http.server import HTTPServer
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
VENDOR = ROOT / "ui" / "vendor" / "whynot-design"
REQUIRED_VENDOR_FILES = [
VENDOR / ".whynot-design-ref",
VENDOR / "colors_and_type.css",
VENDOR / "components.css",
VENDOR / "index.js",
VENDOR / "elements" / "atoms.js",
VENDOR / "elements" / "chrome.js",
VENDOR / "elements" / "form.js",
VENDOR / "elements" / "layout.js",
VENDOR / "elements" / "icons.js",
VENDOR / "elements" / "_styles.js",
VENDOR / "tokens" / "colors.json",
]
def test_vendor_tree_is_complete() -> None:
missing = [path for path in REQUIRED_VENDOR_FILES if not path.exists()]
assert not missing, f"Missing vendored whynot-design files: {missing}"
def test_vendor_ref_is_pinned() -> None:
ref = (VENDOR / ".whynot-design-ref").read_text(encoding="utf-8").strip()
assert len(ref) == 40
def test_server_serves_vendor_modules() -> None:
server_module = importlib.import_module("observatory.server")
handler = server_module.ObservatoryHandler
handler.data_dir = ROOT / "data"
httpd = HTTPServer(("127.0.0.1", 0), handler)
port = httpd.server_address[1]
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
try:
import urllib.request
index = urllib.request.urlopen(f"http://127.0.0.1:{port}/", timeout=2)
assert "wn-top-nav" in index.read().decode("utf-8")
module = urllib.request.urlopen(
f"http://127.0.0.1:{port}/ui/vendor/whynot-design/index.js",
timeout=2,
)
assert module.headers["Content-Type"].startswith("application/javascript")
assert "defineAtoms" in module.read().decode("utf-8")
finally:
httpd.shutdown()
thread.join(timeout=2)

View File

@@ -0,0 +1,416 @@
const euro = (value) => `${Number(value).toFixed(2)}`;
const SECTION_META = {
"cost-floor": {
eyebrow: "Economic Observatory · Cost Floor",
title: "Operator liquidity",
lede: (data) =>
`Period ${data.period}. Minimum viable economics: platform cost, margin, and liquidity burn from ledgers.`,
},
"value-range": {
eyebrow: "Economic Observatory · Value Range",
title: "Customer value bands",
lede: (data) =>
`Period ${data.period}. Segment-level willingness-to-pay hypotheses above the cost floor.`,
},
"market-price": {
eyebrow: "Economic Observatory · Market Price",
title: "Competitive context",
lede: (data) =>
`Reviewed ${data.market_price.last_reviewed ?? "—"}. Evidence about alternatives, capabilities, and public pricing.`,
},
};
let currentData = null;
let activeSection = "cost-floor";
function normalizeData(data) {
const snapshot = data.snapshot ?? {};
const active = (data.pricing_models ?? []).find((model) => model.status === "active");
if (!data.cost_floor) {
data.cost_floor = {
cost_per_member: snapshot.cost_per_member ?? "0",
monthly_total_platform_cost: snapshot.monthly_total_platform_cost ?? "0",
active_price: active?.access_fee_amount ?? snapshot.monthly_revenue ?? "0",
active_model_name: active?.name ?? null,
};
}
if (!data.value_range) {
data.value_range = {
current_price_eur: active?.access_fee_amount ?? snapshot.monthly_revenue ?? "0",
aggregate_low_eur: active?.access_fee_amount ?? "0",
aggregate_high_eur: active?.access_fee_amount ?? "0",
cost_per_member_eur: snapshot.cost_per_member ?? "0",
segments: [],
value_drivers: [],
notes: "Value range data unavailable — restart observatory.server to load the latest API.",
};
}
if (!data.market_price) {
data.market_price = {
alternative_count: 0,
priced_alternative_count: 0,
market_low_eur: null,
market_high_eur: null,
last_reviewed: null,
alternatives: [],
notes: "Market signals unavailable — restart observatory.server to load the latest API.",
};
}
return data;
}
async function loadDashboard(period) {
const query = period ? `?period=${encodeURIComponent(period)}` : "";
const response = await fetch(`/api/dashboard${query}`);
if (!response.ok) throw new Error("Failed to load dashboard");
return response.json();
}
function setBadge(el, status) {
el.textContent = status;
el.active = status === "generating" || status === "neutral";
el.draft = status === "burning";
}
function setSection(sectionId) {
activeSection = sectionId;
document.querySelectorAll(".obs-panel").forEach((panel) => {
const active = panel.dataset.section === sectionId;
panel.hidden = !active;
panel.classList.toggle("obs-panel--active", active);
});
document.querySelectorAll("wn-sidebar-item[data-section]").forEach((item) => {
item.active = item.dataset.section === sectionId;
});
const meta = SECTION_META[sectionId];
const header = document.getElementById("page-header");
header.eyebrow = meta.eyebrow;
header.title = meta.title;
if (currentData) {
header.lede = meta.lede(currentData);
}
const badge = document.getElementById("liquidity-badge");
badge.style.display = sectionId === "cost-floor" ? "" : "none";
}
function bindNavigation() {
document.querySelectorAll("wn-sidebar-item[data-section]").forEach((item) => {
item.addEventListener("click", () => setSection(item.dataset.section));
});
}
function renderCostFloor(data) {
const { snapshot, liquidity } = data;
const cards = [
{
label: "Remaining budget",
value: euro(liquidity.remaining_budget),
sub: `${liquidity.months_tracked} months tracked`,
},
{
label: "Period net liquidity",
value: euro(snapshot.period_net_liquidity),
sub: snapshot.liquidity_status,
},
{
label: "Cost per member",
value: euro(data.cost_floor.cost_per_member),
sub: `Platform ${euro(data.cost_floor.monthly_total_platform_cost)} / mo`,
},
{
label: "Gross margin",
value: euro(snapshot.gross_margin),
sub: `${snapshot.gross_margin_pct}% of gross revenue`,
},
{
label: "Infrastructure / month",
value: euro(snapshot.monthly_infrastructure_cost),
sub: `Processing ${euro(snapshot.monthly_payment_processing_cost)}`,
},
{
label: "Active price",
value: euro(data.cost_floor.active_price),
sub: data.cost_floor.active_model_name ?? "—",
},
];
document.getElementById("metric-grid").innerHTML = cards
.map(
(card) => `
<wn-card size="sm">
<wn-eyebrow slot="header">${card.label}</wn-eyebrow>
<div class="obs-metric__value">${card.value}</div>
<span slot="footer">${card.sub}</span>
</wn-card>`
)
.join("");
const initial = Number(liquidity.initial_budget);
const remaining = Number(liquidity.remaining_budget);
const pct = Math.max(0, Math.min(100, (remaining / initial) * 100));
const banner = document.getElementById("budget-banner");
document.getElementById("budget-fill").style.width = `${pct}%`;
document.getElementById("budget-caption").textContent =
remaining >= 0 ? "Within allocated budget" : "Over allocated budget";
banner.variant = remaining < initial * 0.25 ? "warn" : "info";
document.getElementById("budget-stats").innerHTML = [
["Initial budget", euro(initial)],
["Cumulative member payments (net)", euro(liquidity.cumulative_member_payments)],
["Cumulative infrastructure", euro(liquidity.cumulative_infrastructure_cost)],
["Cumulative processing", euro(liquidity.cumulative_payment_processing_cost)],
]
.map(
([label, value]) => `
<wn-field-row label="${label}" narrow>
<span class="wn-field-row__value">${value}</span>
</wn-field-row>`
)
.join("");
const max = Math.max(...data.history.map((row) => Math.abs(Number(row.net_liquidity))), 1);
document.getElementById("liquidity-chart").innerHTML = data.history
.map((row) => {
const value = Number(row.net_liquidity);
const width = (Math.abs(value) / max) * 50;
const barClass = value < 0 ? "obs-liquidity-row__bar--neg" : "obs-liquidity-row__bar--pos";
const valueClass = value < 0 ? "obs-liquidity-row__value--neg" : "";
return `
<div class="obs-liquidity-row">
<div class="obs-liquidity-row__period">${row.period}</div>
<div class="obs-liquidity-row__bar-wrap">
<div class="obs-liquidity-row__bar ${barClass}" style="width:${width}%"></div>
</div>
<div class="obs-liquidity-row__value ${valueClass}">${euro(value)}</div>
</div>`;
})
.join("");
const domains = data.infrastructure.domains?.domains ?? [];
const servers = data.infrastructure.virtual_servers?.servers ?? [];
const stripe = data.infrastructure.stripe?.membership ?? {};
const payout = data.infrastructure.stripe?.payout_account ?? "payout";
const infraItems = [
...domains.map(
(d) =>
`<wn-field-row label="${d.name}" narrow><span class="wn-field-row__value">${d.monthly_eur} EUR/mo · ${d.tld}</span></wn-field-row>`
),
...servers.map(
(s) =>
`<wn-field-row label="${s.name}" narrow><span class="wn-field-row__value">${s.monthly_eur} EUR/mo · since ${s.started}</span></wn-field-row>`
),
`<wn-field-row label="Stripe · ${stripe.member_username ?? "member"}" narrow><span class="wn-field-row__value">${stripe.gross_monthly_eur} gross · ${stripe.fee_monthly_eur} fee · ${stripe.net_payout_monthly_eur} net to ${payout}</span></wn-field-row>`,
];
document.getElementById("infra-stack").innerHTML = `<div class="obs-infra-list">${infraItems.join("")}</div>`;
document.getElementById("history-body").innerHTML = data.history
.map(
(row) => `
<tr>
<td class="mono">${row.period}</td>
<td class="mono">${row.active_members}</td>
<td class="obs-num mono">${euro(row.gross_revenue)}</td>
<td class="obs-num mono">${euro(row.infrastructure_cost)}</td>
<td class="obs-num mono">${euro(row.payment_processing_cost)}</td>
<td class="obs-num mono">${euro(row.net_liquidity)}</td>
</tr>`
)
.join("");
document.getElementById("pricing-body").innerHTML = data.pricing_models
.map(
(model) => `
<tr>
<td class="mono">${model.id}</td>
<td>${model.name}</td>
<td class="mono">${model.model_type}</td>
<td><wn-tag>${model.status}</wn-tag></td>
</tr>`
)
.join("");
setBadge(document.getElementById("liquidity-badge"), snapshot.liquidity_status);
}
function renderValueRangeBand(low, high, current) {
const min = Number(low);
const max = Number(high);
const cur = Number(current);
const span = Math.max(max - min, 0.01);
const curPct = Math.max(0, Math.min(100, ((cur - min) / span) * 100));
return `
<div class="obs-range">
<div class="obs-range__labels">
<span class="mono">${euro(min)}</span>
<span class="mono obs-range__current">${euro(cur)} now</span>
<span class="mono">${euro(max)}</span>
</div>
<div class="obs-range__track">
<div class="obs-range__fill" style="width:${curPct}%"></div>
<div class="obs-range__marker" style="left:${curPct}%"></div>
</div>
</div>`;
}
function renderValueRange(data) {
const vr = data.value_range;
document.getElementById("value-summary").innerHTML = [
{
label: "Current price",
value: euro(vr.current_price_eur),
sub: data.cost_floor.active_model_name ?? "—",
},
{
label: "Aggregate band",
value: `${euro(vr.aggregate_low_eur)} ${euro(vr.aggregate_high_eur)}`,
sub: `${vr.segments.length} segments`,
},
{
label: "Cost per member",
value: euro(vr.cost_per_member_eur),
sub: "Cost floor reference",
},
]
.map(
(card) => `
<wn-card size="sm">
<wn-eyebrow slot="header">${card.label}</wn-eyebrow>
<div class="obs-metric__value obs-metric__value--sm">${card.value}</div>
<span slot="footer">${card.sub}</span>
</wn-card>`
)
.join("");
document.getElementById("value-range-notes").textContent = vr.notes || "";
document.getElementById("value-segments").innerHTML = vr.segments
.map(
(segment) => `
<wn-card>
<wn-eyebrow slot="header">${segment.name}</wn-eyebrow>
<wn-tag slot="header">${segment.confidence}</wn-tag>
${renderValueRangeBand(segment.low_eur, segment.high_eur, vr.current_price_eur)}
<p class="small">${segment.evidence}</p>
<span slot="footer">Headroom ${euro(segment.headroom_to_high_eur)}</span>
<span slot="footer">${segment.drivers.join(" · ")}</span>
</wn-card>`
)
.join("");
document.getElementById("value-drivers").innerHTML = vr.value_drivers
.map(
(driver) => `
<wn-field-row label="${driver.label}" narrow>
<span class="wn-field-row__value">${driver.note}</span>
<span class="wn-field-row__aside">${driver.strength}</span>
</wn-field-row>`
)
.join("");
}
function renderMarketPrice(data) {
const market = data.market_price;
const span =
market.market_low_eur != null && market.market_high_eur != null
? `${euro(market.market_low_eur)} ${euro(market.market_high_eur)}`
: "—";
document.getElementById("market-summary").innerHTML = [
{
label: "Alternatives tracked",
value: market.alternative_count,
sub: `${market.priced_alternative_count} with public monthly price`,
},
{
label: "Priced span",
value: span,
sub: `Reviewed ${market.last_reviewed ?? "—"}`,
},
{
label: "Coulomb list price",
value: euro(data.value_range.current_price_eur),
sub: data.cost_floor.active_model_name ?? "—",
},
]
.map(
(card) => `
<wn-card size="sm">
<wn-eyebrow slot="header">${card.label}</wn-eyebrow>
<div class="obs-metric__value obs-metric__value--sm">${card.value}</div>
<span slot="footer">${card.sub}</span>
</wn-card>`
)
.join("");
document.getElementById("market-notes").textContent = market.notes || "";
document.getElementById("market-body").innerHTML = market.alternatives
.map((alt) => {
const price =
alt.price_monthly_eur && Number(alt.price_monthly_eur) > 0
? euro(alt.price_monthly_eur)
: "—";
const features = (alt.features ?? []).join(", ");
return `
<tr>
<td><strong>${alt.name}</strong><div class="small mono">${alt.id}</div></td>
<td><wn-tag>${alt.category}</wn-tag></td>
<td class="obs-num mono">${price}</td>
<td><wn-stage-dot level="${alt.signal_level}">${alt.signal_level}</wn-stage-dot></td>
<td>${features}</td>
<td class="small">${alt.source} · ${alt.observed}<br>${alt.note}</td>
</tr>`;
})
.join("");
}
function populatePeriods(history, current) {
const select = document.getElementById("period-select");
select.innerHTML = [...history].reverse().map(
(row) => `<option value="${row.period}" ${row.period === current ? "selected" : ""}>${row.period}</option>`
);
select.value = current;
if (!select._obsBound) {
select.addEventListener("wn-change", async (event) => {
const next = await loadDashboard(event.detail.value);
render(next);
});
select._obsBound = true;
}
}
async function loadDesignRefLabel() {
try {
const response = await fetch("/ui/vendor/whynot-design/.whynot-design-ref");
if (!response.ok) return;
const ref = (await response.text()).trim().slice(0, 7);
const label = document.getElementById("design-ref-label");
if (label && ref) label.textContent = `whynot-design @ ${ref}`;
} catch {
// optional footer detail
}
}
function render(data) {
currentData = normalizeData(data);
document.getElementById("design-link").href = data.design_reference;
loadDesignRefLabel();
setSection(activeSection);
renderCostFloor(data);
renderValueRange(data);
renderMarketPrice(data);
populatePeriods(data.history, data.period);
}
bindNavigation();
loadDashboard()
.then(render)
.catch((error) => {
document.body.innerHTML = `<main class="wn-main"><wn-banner variant="error" title="Load failed">${error}</wn-banner></main>`;
});

View File

@@ -0,0 +1,208 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Coulomb · Economic Observatory</title>
<link rel="stylesheet" href="/ui/vendor/whynot-design/colors_and_type.css" />
<link rel="stylesheet" href="/ui/vendor/whynot-design/components.css" />
<link rel="stylesheet" href="/ui/styles.css" />
<script type="importmap">
{
"imports": {
"lit": "https://esm.sh/lit@3.2.1",
"lit/": "https://esm.sh/lit@3.2.1/"
}
}
</script>
<script type="module" src="/ui/vendor/whynot-design/index.js"></script>
</head>
<body>
<wn-top-nav brand="adaptive-pricing" slug="coulomb · observatory">
<div slot="right" class="obs-period" id="period-control">
<wn-eyebrow>Period</wn-eyebrow>
<wn-select id="period-select"></wn-select>
</div>
<wn-tag slot="right" id="liquidity-badge"></wn-tag>
</wn-top-nav>
<div class="wn-app obs-app">
<wn-sidebar id="obs-sidebar">
<wn-sidebar-group label="Observatory">
<wn-sidebar-item icon="activity" active data-section="cost-floor" id="nav-cost-floor">
Cost Floor
</wn-sidebar-item>
<wn-sidebar-item icon="lightbulb" data-section="value-range" id="nav-value-range">
Value Range
</wn-sidebar-item>
<wn-sidebar-item icon="users" data-section="market-price" id="nav-market-price">
Market Price
</wn-sidebar-item>
</wn-sidebar-group>
</wn-sidebar>
<main class="wn-main obs-main">
<wn-page-header id="page-header"></wn-page-header>
<section class="obs-panel obs-panel--active" id="panel-cost-floor" data-section="cost-floor">
<section class="obs-section">
<div class="obs-section__head">
<wn-eyebrow>Snapshot</wn-eyebrow>
<div class="obs-section__rule"></div>
</div>
<div class="obs-metric-grid" id="metric-grid"></div>
</section>
<section class="obs-section">
<div class="obs-section__head">
<wn-eyebrow>Liquidity &amp; budget</wn-eyebrow>
<div class="obs-section__rule"></div>
</div>
<wn-banner variant="info" title="Budget position" id="budget-banner">
<p id="budget-caption"></p>
</wn-banner>
<div class="obs-budget-meter" aria-hidden="true">
<div class="obs-budget-meter__fill" id="budget-fill"></div>
</div>
<div class="obs-field-sheet" id="budget-stats"></div>
</section>
<div class="obs-split">
<section class="obs-section">
<div class="obs-section__head">
<wn-eyebrow>Monthly net liquidity</wn-eyebrow>
<div class="obs-section__rule"></div>
</div>
<p class="lead obs-section-note">Member net payments minus infrastructure, by period.</p>
<div class="obs-liquidity-list" id="liquidity-chart"></div>
</section>
<section class="obs-section">
<div class="obs-section__head">
<wn-eyebrow>Infrastructure stack</wn-eyebrow>
<div class="obs-section__rule"></div>
</div>
<p class="lead obs-section-note">Domains, hosting, and Stripe reference rates.</p>
<div id="infra-stack"></div>
</section>
</div>
<section class="obs-section">
<div class="obs-section__head">
<wn-eyebrow>Monthly ledger</wn-eyebrow>
<div class="obs-section__rule"></div>
</div>
<p class="lead obs-section-note">Computed from expense and payment record tables.</p>
<div class="obs-table-wrap">
<table class="wn-table--native obs-table">
<thead>
<tr>
<th>Period</th>
<th>Members</th>
<th class="obs-num">Gross</th>
<th class="obs-num">Infrastructure</th>
<th class="obs-num">Processing</th>
<th class="obs-num">Net liquidity</th>
</tr>
</thead>
<tbody id="history-body"></tbody>
</table>
</div>
</section>
<section class="obs-section">
<div class="obs-section__head">
<wn-eyebrow>Pricing model registry</wn-eyebrow>
<div class="obs-section__rule"></div>
</div>
<div class="obs-table-wrap">
<table class="wn-table--native obs-table">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Type</th>
<th>Status</th>
</tr>
</thead>
<tbody id="pricing-body"></tbody>
</table>
</div>
</section>
</section>
<section class="obs-panel" id="panel-value-range" data-section="value-range" hidden>
<section class="obs-section">
<div class="obs-section__head">
<wn-eyebrow>Current position</wn-eyebrow>
<div class="obs-section__rule"></div>
</div>
<div class="obs-metric-grid" id="value-summary"></div>
</section>
<section class="obs-section">
<div class="obs-section__head">
<wn-eyebrow>Segment bands</wn-eyebrow>
<div class="obs-section__rule"></div>
</div>
<p class="lead obs-section-note" id="value-range-notes"></p>
<div class="obs-value-grid" id="value-segments"></div>
</section>
<section class="obs-section">
<div class="obs-section__head">
<wn-eyebrow>Value drivers</wn-eyebrow>
<div class="obs-section__rule"></div>
</div>
<div class="obs-field-sheet" id="value-drivers"></div>
</section>
</section>
<section class="obs-panel" id="panel-market-price" data-section="market-price" hidden>
<section class="obs-section">
<div class="obs-section__head">
<wn-eyebrow>Market span</wn-eyebrow>
<div class="obs-section__rule"></div>
</div>
<div class="obs-metric-grid" id="market-summary"></div>
</section>
<section class="obs-section">
<div class="obs-section__head">
<wn-eyebrow>Competitive alternatives</wn-eyebrow>
<div class="obs-section__rule"></div>
</div>
<p class="lead obs-section-note" id="market-notes"></p>
<div class="obs-table-wrap">
<table class="wn-table--native obs-table">
<thead>
<tr>
<th>Alternative</th>
<th>Category</th>
<th class="obs-num">Price / mo</th>
<th>Signal</th>
<th>Features</th>
<th>Evidence</th>
</tr>
</thead>
<tbody id="market-body"></tbody>
</table>
</div>
</section>
</section>
<footer class="obs-footer">
<p class="small">
Design:
<a id="design-link" href="#" target="_blank" rel="noreferrer">Claude design share</a>
·
<span class="mono" id="design-ref-label">whynot-design</span>
· totals computed programmatically from ledgers
</p>
</footer>
</main>
</div>
<script src="/ui/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,256 @@
/* Observatory layout — extends whynot-design (Layer 1). No gradients, no shadows on cards. */
body {
background: var(--paper);
}
.obs-app {
min-height: calc(100vh - 56px);
}
.obs-main {
padding: var(--sp-6) var(--sp-5) var(--sp-9);
max-width: 960px;
}
.obs-panel[hidden] {
display: none !important;
}
wn-sidebar-item[data-section] {
cursor: pointer;
}
.obs-metric__value--sm {
font-size: 22px;
}
.obs-value-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--sp-3);
}
.obs-range {
display: grid;
gap: var(--sp-2);
}
.obs-range__labels {
display: flex;
justify-content: space-between;
gap: var(--sp-2);
font-size: 12px;
color: var(--fg-3);
}
.obs-range__current {
color: var(--fg-1);
}
.obs-range__track {
position: relative;
height: 10px;
border: 1px solid var(--border);
background: var(--paper-2);
}
.obs-range__fill {
height: 100%;
background: var(--line-strong);
width: 0;
}
.obs-range__marker {
position: absolute;
top: -3px;
width: 2px;
height: 16px;
background: var(--ink);
transform: translateX(-1px);
}
.obs-period {
display: grid;
gap: 6px;
min-width: 132px;
}
wn-top-nav [slot="right"] {
display: flex;
align-items: end;
gap: var(--sp-3);
}
.obs-section {
margin-bottom: var(--sp-7);
}
.obs-section__head {
display: flex;
align-items: center;
gap: var(--sp-3);
margin-bottom: var(--sp-4);
}
.obs-section__rule {
flex: 1;
border-top: 1px solid var(--border-soft);
}
.obs-section-note {
margin: 0 0 var(--sp-4);
max-width: 60ch;
}
.obs-metric-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--sp-3);
}
.obs-metric__value {
font: 500 28px/1.1 var(--ff-sans);
letter-spacing: -0.02em;
color: var(--fg-1);
font-variant-numeric: tabular-nums;
}
.obs-budget-meter {
height: 8px;
border: 1px solid var(--border);
background: var(--paper-2);
margin-bottom: var(--sp-4);
}
.obs-budget-meter__fill {
height: 100%;
width: 0;
background: var(--ink);
transition: width 0.25s ease;
}
.obs-field-sheet {
border: 1px solid var(--border);
border-radius: var(--r-2);
background: var(--paper);
padding: 0 var(--sp-4);
}
.obs-split {
display: grid;
grid-template-columns: 1.35fr 1fr;
gap: var(--sp-6);
margin-bottom: var(--sp-2);
}
.obs-liquidity-list {
border: 1px solid var(--border);
border-radius: var(--r-2);
background: var(--paper);
padding: 0 var(--sp-4);
}
.obs-liquidity-row {
display: grid;
grid-template-columns: 72px 1fr 88px;
gap: var(--sp-3);
align-items: center;
padding: var(--sp-3) 0;
border-bottom: 1px solid var(--border-soft);
}
.obs-liquidity-row:last-child {
border-bottom: 0;
}
.obs-liquidity-row__period {
font: 400 12px var(--ff-mono);
color: var(--fg-3);
}
.obs-liquidity-row__value {
font: 500 12px var(--ff-mono);
color: var(--fg-1);
text-align: right;
font-variant-numeric: tabular-nums;
}
.obs-liquidity-row__value--neg {
color: var(--fg-2);
}
.obs-liquidity-row__bar-wrap {
height: 10px;
border: 1px solid var(--border-soft);
background: var(--paper-2);
position: relative;
}
.obs-liquidity-row__bar {
position: absolute;
top: 0;
bottom: 0;
background: var(--ink);
}
.obs-liquidity-row__bar--neg {
right: 50%;
background: var(--ink-4);
}
.obs-liquidity-row__bar--pos {
left: 50%;
background: var(--ink);
}
.obs-infra-list {
border: 1px solid var(--border);
border-radius: var(--r-2);
background: var(--paper);
padding: 0 var(--sp-4);
}
.obs-table-wrap {
border: 1px solid var(--border);
border-radius: var(--r-2);
overflow-x: auto;
background: var(--paper);
}
.obs-table {
width: 100%;
margin: 0;
}
.obs-num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.obs-footer {
padding-top: var(--sp-5);
border-top: 1px solid var(--border);
}
.obs-footer a {
color: var(--fg-1);
text-decoration: underline;
text-underline-offset: 2px;
}
wn-banner {
display: block;
margin-bottom: var(--sp-4);
}
@media (max-width: 900px) {
.obs-split {
grid-template-columns: 1fr;
}
wn-top-nav [slot="right"] {
width: 100%;
justify-content: space-between;
}
}

View File

@@ -0,0 +1 @@
9b9f3728937ca308966de9c62accdb00c8cf5b0e

View File

@@ -0,0 +1,273 @@
/* ============================================================
WhyNot Design System — Colors & Type
------------------------------------------------------------
Neutral, mostly black/white. Color is used SPARINGLY — only
one warm accent (annotation yellow) borrowed from the LEGO
brick in the logo. The system favours light grey wireframe
artefacts over heavy fills.
============================================================ */
/* ---------- Webfonts (Google Fonts, see /fonts for offline) ---------- */
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600;700&family=IBM+Plex+Serif:ital,wght@0,400;0,500;1,400&display=swap");
:root {
/* ---------- Base palette: neutrals ---------- */
--ink: #0A0A0A; /* near-black, the only "fill" most of the time */
--ink-2: #1F1F1F;
--ink-3: #5C5C5C;
--ink-4: #8A8A8A;
--ink-5: #B5B5B3; /* placeholder text, wireframe labels */
--line: #E5E5E2; /* default 1px wireframe rule */
--line-strong: #C9C9C5; /* dividers between sections */
--line-soft: #F0F0EC; /* hairline within a card */
--paper: #FFFFFF; /* canvas */
--paper-2: #FAFAF7; /* sheet, dim canvas */
--paper-3: #F4F4EF; /* recessed surface, code block bg */
/* ---------- Foreground / background semantic ---------- */
--fg-1: var(--ink);
--fg-2: var(--ink-3);
--fg-3: var(--ink-4);
--fg-mute: var(--ink-5);
--fg-on-dark: #FAFAF7;
--bg-1: var(--paper);
--bg-2: var(--paper-2);
--bg-3: var(--paper-3);
--bg-invert: var(--ink);
--border: var(--line);
--border-strong: var(--line-strong);
--border-soft: var(--line-soft);
/* ---------- The single accent: annotation yellow ---------- */
/* Lifted from the LEGO brick. Used as highlighter, "draft"
stamp, signal-marker. Never as a button fill. */
--hi: #FFE14A;
--hi-2: #FFD400;
--hi-ink: #1A1500; /* text on yellow */
/* ---------- Status (for prototype lifecycle, signal strength) ---------- */
/* Kept deliberately desaturated so they read as labels, not UI. */
--status-raw: #B5B5B3; /* S0 — no signal */
--status-weak: #8A8A8A; /* S1 — weak signal */
--status-medium: #5C5C5C; /* S2 — medium signal */
--status-strong: #0A0A0A; /* S3 — strong signal */
--status-commercial: #FFD400; /* S4 — commercial */
/* ---------- Type families ---------- */
--ff-sans: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
--ff-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
--ff-serif: "IBM Plex Serif", "Iowan Old Style", Georgia, serif;
/* ---------- Type scale (modular, ~1.2) ---------- */
--fs-xs: 11px;
--fs-sm: 13px;
--fs-base: 15px;
--fs-md: 17px;
--fs-lg: 20px;
--fs-xl: 24px;
--fs-2xl: 32px;
--fs-3xl: 44px;
--fs-4xl: 64px;
--fs-5xl: 96px;
--lh-tight: 1.05;
--lh-snug: 1.25;
--lh-base: 1.5;
--lh-loose: 1.7;
--tr-tight: -0.02em;
--tr-snug: -0.01em;
--tr-base: 0em;
--tr-mono: 0.02em;
--tr-label: 0.08em; /* uppercase eyebrow labels */
/* ---------- Spacing (4px base) ---------- */
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 24px;
--sp-6: 32px;
--sp-7: 48px;
--sp-8: 64px;
--sp-9: 96px;
--sp-10: 128px;
/* ---------- Radii — small, mostly square ---------- */
--r-0: 0px;
--r-1: 2px;
--r-2: 4px;
--r-3: 8px;
--r-pill: 999px;
/* ---------- Elevation — almost none. This is a wireframe system. ---------- */
--shadow-0: none;
--shadow-1: 0 1px 0 var(--line);
--shadow-2: 0 1px 0 var(--line-strong);
--shadow-3: 0 4px 12px -6px rgba(10,10,10,0.10);
}
/* ============================================================
Semantic element styles
============================================================ */
html {
font-family: var(--ff-sans);
font-size: var(--fs-base);
line-height: var(--lh-base);
color: var(--fg-1);
background: var(--bg-1);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
margin: 0;
font-feature-settings: "ss01", "cv11";
text-wrap: pretty;
}
/* ---------- Headings ---------- */
h1, .h1 {
font: 600 var(--fs-3xl)/var(--lh-tight) var(--ff-sans);
letter-spacing: var(--tr-tight);
margin: 0 0 var(--sp-5);
color: var(--fg-1);
}
h2, .h2 {
font: 500 var(--fs-2xl)/var(--lh-snug) var(--ff-sans);
letter-spacing: var(--tr-snug);
margin: 0 0 var(--sp-4);
}
h3, .h3 {
font: 500 var(--fs-xl)/var(--lh-snug) var(--ff-sans);
letter-spacing: var(--tr-snug);
margin: 0 0 var(--sp-3);
}
h4, .h4 {
font: 500 var(--fs-lg)/var(--lh-snug) var(--ff-sans);
margin: 0 0 var(--sp-2);
}
h5, .h5 {
font: 500 var(--fs-md)/var(--lh-snug) var(--ff-sans);
margin: 0 0 var(--sp-2);
}
/* ---------- Display (for hero / title slides) ---------- */
.display-1 {
font: 300 var(--fs-5xl)/0.95 var(--ff-sans);
letter-spacing: -0.035em;
color: var(--fg-1);
}
.display-2 {
font: 400 var(--fs-4xl)/1.0 var(--ff-sans);
letter-spacing: var(--tr-tight);
}
/* ---------- Body ---------- */
p {
margin: 0 0 var(--sp-4);
line-height: var(--lh-base);
color: var(--fg-1);
}
.lead {
font-size: var(--fs-md);
line-height: 1.55;
color: var(--fg-2);
}
small, .small {
font-size: var(--fs-sm);
color: var(--fg-2);
}
/* ---------- Eyebrow / uppercase labels (very common in this system) ---------- */
.eyebrow,
.label {
font: 500 var(--fs-xs)/1.2 var(--ff-mono);
letter-spacing: var(--tr-label);
text-transform: uppercase;
color: var(--fg-3);
}
/* ---------- Code / mono ---------- */
code, kbd, samp, pre, .mono {
font-family: var(--ff-mono);
font-size: 0.92em;
letter-spacing: var(--tr-mono);
}
code {
background: var(--bg-3);
padding: 1px 6px;
border-radius: var(--r-1);
color: var(--ink-2);
}
pre {
background: var(--bg-3);
border: 1px solid var(--border);
padding: var(--sp-4);
overflow-x: auto;
border-radius: var(--r-2);
font-size: var(--fs-sm);
line-height: var(--lh-snug);
}
pre code { background: none; padding: 0; }
/* ---------- Editorial serif moments ---------- */
.serif { font-family: var(--ff-serif); }
.serif-quote {
font: 400 italic var(--fs-xl)/1.4 var(--ff-serif);
color: var(--fg-2);
}
/* ---------- Links ---------- */
a {
color: var(--fg-1);
text-decoration: underline;
text-decoration-color: var(--border-strong);
text-underline-offset: 3px;
text-decoration-thickness: 1px;
transition: text-decoration-color 120ms ease, color 120ms ease;
}
a:hover {
text-decoration-color: var(--fg-1);
}
/* ---------- HR ---------- */
hr {
border: 0;
border-top: 1px solid var(--border);
margin: var(--sp-5) 0;
}
/* ---------- Highlighter (the one place yellow appears in body copy) ---------- */
mark, .mark {
background: var(--hi);
color: var(--hi-ink);
padding: 0 2px;
}
/* ---------- Tables (used in templates) ---------- */
table {
width: 100%;
border-collapse: collapse;
font-size: var(--fs-sm);
}
th, td {
text-align: left;
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border);
}
th {
font-weight: 500;
color: var(--fg-2);
font-family: var(--ff-mono);
font-size: var(--fs-xs);
letter-spacing: var(--tr-label);
text-transform: uppercase;
}
/* ---------- Selection ---------- */
::selection { background: var(--hi); color: var(--hi-ink); }

View File

@@ -0,0 +1,590 @@
/* ============================================================
WhyNot Design System — Component Styles
------------------------------------------------------------
Utility classes that the Lit web components render to. These
are also consumable directly from any HTML (no JS required)
for the "Layer 1 only" use case — see MultiFrameworkSupport.md.
============================================================ */
/* ====== Custom-element display defaults ======
* For shadow-DOM components, the wn-* host has display: inline by default.
* Set sensible defaults so layout works without the consumer specifying them.
*/
wn-eyebrow, wn-tag, wn-stage-dot, wn-phase-dot, wn-stamp, wn-icon,
wn-search-input, wn-button { display: inline-block; }
wn-card, wn-modal, wn-top-nav, wn-sidebar, wn-page-header,
wn-pipeline, wn-prototype-card, wn-field-row, wn-breadcrumb,
wn-table, wn-banner, wn-empty-state,
wn-input, wn-textarea, wn-select { display: block; }
wn-toast-region { display: block; }
wn-toast { display: block; }
wn-sidebar-group, wn-sidebar-item { display: block; }
wn-table-row, wn-table-cell { display: contents; }
/* host hidden state — needed because shadow-DOM components don't inherit
* `[hidden]` semantics in light DOM. Lit's host attribute reflection
* handles attributes, but `hidden` on the host itself should still work. */
[hidden] { display: none !important; }
/* ====== Buttons ====== */
.wn-btn {
font: 500 13px var(--ff-sans);
letter-spacing: -0.005em;
padding: 9px 16px;
border-radius: var(--r-2);
border: 1px solid var(--border);
background: var(--paper);
color: var(--ink);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
text-decoration: none;
line-height: 1.2;
}
.wn-btn:hover { border-color: var(--ink); }
.wn-btn:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.wn-btn:active { background: var(--bg-3); }
.wn-btn[disabled], .wn-btn.is-disabled {
color: var(--ink-5); border-color: var(--border); cursor: not-allowed; background: var(--paper);
}
.wn-btn--primary { background: var(--ink); color: var(--paper); border-color: var(--ink); }
.wn-btn--primary:hover { background: var(--ink-2); border-color: var(--ink-2); }
.wn-btn--primary:active { background: var(--ink); }
.wn-btn--primary[disabled], .wn-btn--primary.is-disabled {
background: var(--ink-5); border-color: var(--ink-5); color: var(--paper);
}
.wn-btn--ghost { background: transparent; border-color: transparent; padding: 7px 10px; }
.wn-btn--ghost:hover { background: var(--bg-3); border-color: transparent; }
.wn-btn--danger { background: var(--paper); color: var(--ink); border-color: var(--ink); }
.wn-btn--sm { padding: 5px 10px; font-size: 12px; }
.wn-btn--lg { padding: 12px 20px; font-size: 14px; }
.wn-btn__icon { width: 14px; height: 14px; flex: none; }
.wn-btn--lg .wn-btn__icon { width: 16px; height: 16px; }
/* ====== Eyebrows & labels ====== */
.wn-eyebrow {
font: 500 11px/1.2 var(--ff-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--fg-3);
display: inline-block;
}
.wn-eyebrow--strong { color: var(--fg-1); }
/* ====== Tags ====== */
.wn-tag {
font: 500 10px/1 var(--ff-mono);
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 5px 10px;
border-radius: var(--r-pill);
border: 1px solid var(--border);
color: var(--fg-2);
background: var(--paper);
display: inline-block;
white-space: nowrap;
}
.wn-tag--active { background: var(--ink); color: var(--paper); border-color: var(--ink); }
.wn-tag--draft { background: var(--hi); color: var(--hi-ink); border-color: transparent; }
/* ====== Stage / Phase dots ====== */
.wn-dot {
display: inline-flex;
align-items: center;
gap: 6px;
font: 500 10px/1 var(--ff-mono);
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--fg-2);
}
.wn-dot__bullet { width: 8px; height: 8px; border-radius: 999px; background: var(--ink); flex: none; }
/* signal levels (S0S4) */
.wn-stage-dot--s0 .wn-dot__bullet { background: var(--status-raw); }
.wn-stage-dot--s1 .wn-dot__bullet { background: var(--status-weak); }
.wn-stage-dot--s2 .wn-dot__bullet { background: var(--status-medium); }
.wn-stage-dot--s3 .wn-dot__bullet { background: var(--status-strong); }
.wn-stage-dot--s4 .wn-dot__bullet { background: var(--status-commercial); }
/* phase states (todo / active / done / warn) — numbered phases, distinct from signal */
.wn-phase-dot__bullet {
width: 18px; height: 18px; border-radius: 999px;
border: 1px solid var(--border-strong);
background: var(--paper);
display: inline-flex; align-items: center; justify-content: center;
font: 500 10px/1 var(--ff-mono); color: var(--fg-3);
flex: none;
}
.wn-phase-dot--todo .wn-phase-dot__bullet { border-color: var(--border-strong); color: var(--fg-3); background: var(--paper); }
.wn-phase-dot--active .wn-phase-dot__bullet { border-color: var(--ink); color: var(--ink); background: var(--paper); box-shadow: 0 0 0 3px rgba(10,10,10,0.06); }
.wn-phase-dot--done .wn-phase-dot__bullet { border-color: var(--ink); color: var(--paper); background: var(--ink); }
.wn-phase-dot--warn .wn-phase-dot__bullet { border-color: var(--hi-2); color: var(--hi-ink); background: var(--hi); }
/* ====== Stamp ====== */
.wn-stamp {
display: inline-block;
background: var(--hi);
color: var(--hi-ink);
padding: 5px 10px 3px;
font: 500 10px/1 var(--ff-mono);
letter-spacing: 0.12em;
text-transform: uppercase;
transform: rotate(-1.5deg);
}
/* ====== Icon ====== */
.wn-icon { stroke-width: 1.5; stroke: currentColor; fill: none; display: inline-block; vertical-align: middle; }
.wn-icon--sm { width: 14px; height: 14px; }
.wn-icon--md { width: 16px; height: 16px; }
.wn-icon--lg { width: 20px; height: 20px; }
.wn-icon--xl { width: 24px; height: 24px; }
/* ====== Card ====== */
.wn-card {
background: var(--paper);
border: 1px solid var(--border);
border-radius: var(--r-2);
padding: var(--sp-5);
display: flex;
flex-direction: column;
gap: var(--sp-3);
position: relative;
}
.wn-card--inset { background: var(--paper-2); border-color: var(--border); }
.wn-card--recessed { background: var(--paper-3); }
.wn-card--lg { padding: var(--sp-6); border-radius: var(--r-3); }
.wn-card--sm { padding: var(--sp-4); gap: var(--sp-2); }
.wn-card--clickable { cursor: pointer; transition: border-color 120ms ease; }
.wn-card--clickable:hover { border-color: var(--ink); }
.wn-card--clickable:hover::before {
content: ""; position: absolute; left: -1px; top: -1px; bottom: -1px;
width: 2px; background: var(--ink); border-radius: 2px 0 0 2px;
}
.wn-card__head { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-3); }
.wn-card__title { font: 500 17px/1.35 var(--ff-sans); margin: 4px 0 8px; color: var(--fg-1); }
.wn-card__foot {
display: flex; justify-content: space-between; gap: var(--sp-3);
padding-top: var(--sp-3); margin-top: 4px;
border-top: 1px solid var(--border-soft);
font: 500 11px var(--ff-mono); letter-spacing: 0.06em; text-transform: uppercase;
color: var(--fg-3);
}
/* ====== Field row (label + value, 3-col grid) ====== */
.wn-field-row {
display: grid;
grid-template-columns: 200px 1fr auto;
gap: var(--sp-4) var(--sp-5);
padding: var(--sp-3) 0;
border-bottom: 1px solid var(--border-soft);
align-items: baseline;
}
.wn-field-row:last-child { border-bottom: 0; }
.wn-field-row__label {
font: 500 11px/1.5 var(--ff-mono);
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--fg-3);
}
.wn-field-row__value { font: 400 15px/1.55 var(--ff-sans); color: var(--fg-1); }
.wn-field-row__aside { font: 400 12px var(--ff-mono); color: var(--fg-3); text-align: right; }
.wn-field-row--stacked { grid-template-columns: 1fr; gap: 6px; }
.wn-field-row--narrow { grid-template-columns: 120px 1fr; }
/* ====== Form inputs ====== */
.wn-form-label {
font: 500 11px/1 var(--ff-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--fg-3);
display: block;
margin-bottom: 6px;
}
.wn-input, .wn-textarea, .wn-select {
font: 400 14px var(--ff-sans);
padding: 10px 12px;
border: 1px solid var(--border);
background: var(--paper);
border-radius: var(--r-1);
color: var(--fg-1);
outline: none;
width: 100%;
transition: border-color 120ms ease;
box-sizing: border-box;
}
.wn-input:hover, .wn-textarea:hover, .wn-select:hover { border-color: var(--border-strong); }
.wn-input:focus, .wn-textarea:focus, .wn-select:focus { border-color: var(--ink); }
.wn-input::placeholder, .wn-textarea::placeholder { color: var(--ink-5); }
.wn-input[disabled], .wn-textarea[disabled], .wn-select[disabled] {
background: var(--paper-2); color: var(--fg-3); cursor: not-allowed;
}
.wn-textarea { resize: vertical; min-height: 96px; font-family: var(--ff-sans); }
.wn-select {
appearance: none; -webkit-appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6' fill='none' stroke='%235C5C5C' stroke-width='1.5'><path d='M1 1l4 4 4-4'/></svg>");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 32px;
}
.wn-input--error, .wn-textarea--error, .wn-select--error {
border-color: var(--ink); border-bottom-width: 2px;
}
.wn-form-help { font: 400 11px var(--ff-mono); color: var(--fg-3); margin-top: 6px; display: block; }
.wn-form-error { font: 400 11px var(--ff-mono); color: var(--ink); margin-top: 6px; display: block; }
/* Search input — extracted from TopNav, also usable standalone */
.wn-search {
display: inline-flex;
align-items: center;
gap: 10px;
border: 1px solid var(--border);
padding: 6px 10px;
border-radius: var(--r-1);
background: var(--paper);
color: var(--fg-3);
font: 400 12px var(--ff-mono);
min-width: 200px;
transition: border-color 120ms ease;
}
.wn-search:focus-within { border-color: var(--ink); }
.wn-search input {
border: 0; outline: 0; background: none; flex: 1;
font: inherit; color: var(--fg-1); padding: 0;
}
.wn-search input::placeholder { color: var(--ink-5); }
.wn-search__kbd {
padding: 1px 5px;
border: 1px solid var(--border);
border-radius: 2px;
font-size: 10px;
color: var(--fg-3);
}
/* ====== Breadcrumb ====== */
.wn-breadcrumb {
display: flex; flex-wrap: wrap; align-items: center;
gap: 6px;
font: 400 12px/1.5 var(--ff-mono);
color: var(--fg-3);
margin-bottom: var(--sp-4);
}
.wn-breadcrumb a {
color: var(--fg-2);
text-decoration: none;
padding: 2px 0;
border-bottom: 1px solid transparent;
transition: border-color 120ms ease, color 120ms ease;
}
.wn-breadcrumb a:hover { color: var(--fg-1); border-bottom-color: var(--border-strong); }
.wn-breadcrumb__sep { color: var(--ink-5); user-select: none; }
.wn-breadcrumb__current { color: var(--fg-1); }
/* ====== Modal / Dialog ====== */
.wn-modal__backdrop {
position: fixed; inset: 0;
background: rgba(10, 10, 10, 0.40);
display: flex; align-items: center; justify-content: center;
z-index: 100;
padding: var(--sp-5);
}
.wn-modal__panel {
background: var(--paper);
border-radius: var(--r-3);
box-shadow: var(--shadow-3);
max-width: 560px; width: 100%;
max-height: calc(100vh - 64px);
display: flex; flex-direction: column;
overflow: hidden;
}
.wn-modal__head {
padding: var(--sp-5) var(--sp-6) var(--sp-4);
border-bottom: 1px solid var(--border);
display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4);
}
.wn-modal__title { font: 500 20px/1.25 var(--ff-sans); margin: 0; color: var(--fg-1); }
.wn-modal__close {
background: none; border: 0; cursor: pointer; padding: 4px;
color: var(--fg-3); border-radius: var(--r-1);
transition: color 120ms ease;
}
.wn-modal__close:hover { color: var(--fg-1); }
.wn-modal__body {
padding: var(--sp-5) var(--sp-6);
overflow-y: auto;
flex: 1;
font: 400 15px/1.6 var(--ff-sans);
color: var(--fg-1);
}
.wn-modal__foot {
padding: var(--sp-4) var(--sp-6) var(--sp-5);
border-top: 1px solid var(--border);
display: flex; justify-content: flex-end; gap: var(--sp-2);
}
/* ====== Table ======
* Note: shadow-DOM-rendered rows can't be children of a real <table> (the
* HTML table model rejects unknown elements between <table> and <tr>). The
* <wn-table> component therefore renders a CSS-grid imitation. For real
* <table> markup (Django QuerySet rendering, etc.) use these classes
* directly on <table>/<tr>/<td> elements — see also the .wn-table--native
* variant below.
*/
/* CSS-grid imitation (default <wn-table>) */
.wn-table {
width: 100%;
font-size: var(--fs-sm);
display: flex;
flex-direction: column;
}
.wn-table__thead { border-bottom: 1px solid var(--border); }
.wn-table__tbody { display: flex; flex-direction: column; }
.wn-table__tr {
display: grid;
gap: var(--sp-4);
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border-soft);
align-items: baseline;
}
.wn-table__tr:last-child { border-bottom: 0; }
.wn-table__tr--head { border-bottom: 0; padding: var(--sp-3) var(--sp-4); }
.wn-table__th {
font: 500 11px/1.2 var(--ff-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--fg-3);
}
.wn-table__td {
color: var(--fg-1);
line-height: 1.5;
font-size: var(--fs-sm);
}
.wn-table--compact .wn-table__tr { padding: var(--sp-2) var(--sp-3); }
.wn-table__cell--mono { font-family: var(--ff-mono); color: var(--fg-2); font-size: 12px; }
.wn-table__cell--meta { color: var(--fg-3); font: 400 12px var(--ff-mono); }
.wn-table__cell--right { text-align: right; }
/* Native <table> variant — for Django QuerySet rendering etc. */
.wn-table--native {
border-collapse: collapse;
display: table;
}
.wn-table--native thead th {
text-align: left;
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border);
font: 500 11px/1.2 var(--ff-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--fg-3);
}
.wn-table--native tbody td {
padding: var(--sp-4);
border-bottom: 1px solid var(--border-soft);
vertical-align: top;
color: var(--fg-1);
font-size: var(--fs-sm);
line-height: 1.5;
}
.wn-table--native tbody tr:hover { background: var(--paper-2); }
.wn-table--native tbody tr:last-child td { border-bottom: 0; }
/* ====== Banner / Toast (success / info / warn) ====== */
.wn-banner {
display: flex;
align-items: flex-start;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
border: 1px solid var(--border);
background: var(--paper);
border-radius: var(--r-2);
font: 400 14px/1.5 var(--ff-sans);
color: var(--fg-1);
position: relative;
}
.wn-banner__icon { color: var(--fg-2); flex: none; padding-top: 2px; }
.wn-banner__body { flex: 1; }
.wn-banner__title {
font: 500 11px/1.2 var(--ff-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--fg-3);
margin: 0 0 4px;
}
.wn-banner__dismiss {
background: none; border: 0; cursor: pointer;
color: var(--fg-3); padding: 4px;
}
.wn-banner__dismiss:hover { color: var(--fg-1); }
.wn-banner--success { border-left: 2px solid var(--ink); }
.wn-banner--warn { border-left: 2px solid var(--hi-2); background: #FFFCEB; }
.wn-banner--error { border-left: 2px solid var(--ink); background: var(--paper); }
.wn-banner--info { border-left: 2px solid var(--border-strong); }
.wn-toast-region {
position: fixed;
bottom: var(--sp-5); right: var(--sp-5);
display: flex; flex-direction: column; gap: var(--sp-2);
z-index: 200;
max-width: 380px;
}
.wn-toast { box-shadow: var(--shadow-3); }
/* ====== Empty state ====== */
.wn-empty {
border: 1px dashed var(--border-strong);
border-radius: var(--r-2);
padding: var(--sp-7);
display: flex; flex-direction: column; align-items: center;
gap: var(--sp-2);
text-align: center;
color: var(--fg-3);
}
.wn-empty__icon { color: var(--fg-3); margin-bottom: var(--sp-2); }
.wn-empty__title { font: 500 14px var(--ff-sans); color: var(--fg-2); margin: 0; }
.wn-empty__body { font: 400 13px/1.5 var(--ff-sans); color: var(--fg-3); max-width: 40ch; margin: 0; }
.wn-empty__cta { margin-top: var(--sp-2); }
/* ====== Top navigation ====== */
.wn-topnav {
height: 56px;
background: rgba(255, 255, 255, 0.92);
border-bottom: 1px solid var(--border);
display: flex; align-items: center;
gap: var(--sp-6);
padding: 0 var(--sp-5);
position: sticky; top: 0; z-index: 10;
}
.wn-topnav__brand { display: flex; align-items: center; gap: 10px; font: 500 14px var(--ff-sans); }
.wn-topnav__brand img { width: 22px; height: 22px; }
.wn-topnav__brand-slug { font-family: var(--ff-mono); font-size: 12px; color: var(--fg-3); letter-spacing: 0.04em; }
.wn-topnav__links { display: flex; gap: 22px; }
.wn-topnav__link {
font: 500 13px var(--ff-sans);
color: var(--fg-2);
text-decoration: none;
padding: 6px 0;
border-bottom: 1px solid transparent;
transition: color 120ms ease, border-color 120ms ease;
}
.wn-topnav__link:hover { color: var(--fg-1); }
.wn-topnav__link--active { color: var(--fg-1); border-bottom-color: var(--ink); }
.wn-topnav__right { margin-left: auto; display: flex; align-items: center; gap: var(--sp-3); }
/* ====== Sidebar ====== */
.wn-sidebar {
width: 240px;
flex: none;
background: var(--paper-2);
border-right: 1px solid var(--border);
padding: var(--sp-5) var(--sp-4);
display: flex; flex-direction: column; gap: var(--sp-5);
height: calc(100vh - 56px);
position: sticky; top: 56px;
overflow-y: auto;
}
.wn-sidebar__group { display: flex; flex-direction: column; gap: 8px; }
.wn-sidebar__group-label { padding-left: 12px; }
.wn-sidebar__item {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px;
border-radius: 4px;
color: var(--fg-2);
font: 500 13px var(--ff-sans);
cursor: pointer; text-decoration: none;
transition: background 120ms ease, color 120ms ease;
}
.wn-sidebar__item:hover { color: var(--fg-1); }
.wn-sidebar__item--active {
color: var(--fg-1); background: var(--paper);
box-shadow: 0 0 0 1px var(--border) inset;
}
.wn-sidebar__item--doc { font-family: var(--ff-mono); font-size: 12px; }
.wn-sidebar__count { margin-left: auto; font: 400 11px var(--ff-mono); color: var(--fg-3); }
.wn-sidebar__footer { margin-top: auto; padding-top: var(--sp-3); border-top: 1px solid var(--border); }
.wn-sidebar__activation {
display: flex; align-items: center; gap: 8px; padding: 6px 12px;
font: 500 11px var(--ff-mono); letter-spacing: 0.06em; text-transform: uppercase;
color: var(--fg-2);
}
.wn-sidebar__activation-dot { width: 6px; height: 6px; border-radius: 999px; background: var(--hi-2); }
/* ====== Page header ====== */
.wn-page-header {
margin-bottom: var(--sp-6);
display: flex; flex-direction: column; gap: 8px;
}
.wn-page-header__row { display: flex; align-items: flex-end; gap: var(--sp-5); }
.wn-page-header__title {
font: 500 32px/1.15 var(--ff-sans);
letter-spacing: -0.015em;
margin: 0; flex: 1; color: var(--fg-1);
}
.wn-page-header__actions { display: flex; gap: 8px; flex-wrap: wrap; }
.wn-page-header__lede {
font: 400 16px/1.55 var(--ff-sans);
color: var(--fg-2);
margin: 0;
max-width: 60ch;
}
/* ====== Pipeline ====== */
.wn-pipeline {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0;
position: relative;
margin: 0 0 var(--sp-6);
}
.wn-pipeline__stage {
padding: 10px 12px 14px;
border-top: 2px solid var(--border);
display: flex; flex-direction: column; gap: 4px;
position: relative;
}
.wn-pipeline__stage--done { border-top-color: var(--ink); }
.wn-pipeline__stage--active { border-top-color: var(--hi-2); }
.wn-pipeline__num {
font: 500 10px/1 var(--ff-mono); letter-spacing: 0.1em; text-transform: uppercase;
color: var(--fg-3);
}
.wn-pipeline__stage--done .wn-pipeline__num,
.wn-pipeline__stage--active .wn-pipeline__num { color: var(--fg-1); }
.wn-pipeline__name { font: 500 14px/1.25 var(--ff-sans); color: var(--fg-1); }
.wn-pipeline__stage--pending .wn-pipeline__name { color: var(--fg-3); }
.wn-pipeline__meta { font: 400 11px/1.35 var(--ff-mono); color: var(--fg-3); }
.wn-pipeline__arrow {
position: absolute; top: -8px; right: -7px;
font: 400 14px var(--ff-mono); color: var(--ink-5);
}
.wn-pipeline__stage--done .wn-pipeline__arrow,
.wn-pipeline__stage--active .wn-pipeline__arrow { color: var(--ink); }
/* ====== Prototype card (combined card variant) ====== */
.wn-prototype-card { /* extends .wn-card */ }
.wn-prototype-card__qrow {
display: grid; grid-template-columns: 110px 1fr; gap: 6px 12px;
font-size: 13px; color: var(--fg-1);
}
.wn-prototype-card__qkey {
font: 500 11px/1.5 var(--ff-mono);
letter-spacing: 0.06em; text-transform: uppercase;
color: var(--fg-3);
}
.wn-prototype-card__qval { line-height: 1.45; }
/* ====== Layout helpers ====== */
.wn-main { padding: 40px 48px 80px; max-width: 1180px; }
.wn-app { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }

View File

@@ -0,0 +1,604 @@
/* Auto-generated from src/styles/components.css by scripts/sync-shared-styles.mjs.
* Do NOT edit by hand. Edit components.css and re-run the script.
*/
export const SHARED_CSS = String.raw`/* ============================================================
WhyNot Design System — Component Styles
------------------------------------------------------------
Utility classes that the Lit web components render to. These
are also consumable directly from any HTML (no JS required)
for the "Layer 1 only" use case — see MultiFrameworkSupport.md.
============================================================ */
/* ====== Custom-element display defaults ======
* For shadow-DOM components, the wn-* host has display: inline by default.
* Set sensible defaults so layout works without the consumer specifying them.
*/
wn-eyebrow, wn-tag, wn-stage-dot, wn-phase-dot, wn-stamp, wn-icon,
wn-search-input, wn-button { display: inline-block; }
wn-card, wn-modal, wn-top-nav, wn-sidebar, wn-page-header,
wn-pipeline, wn-prototype-card, wn-field-row, wn-breadcrumb,
wn-table, wn-banner, wn-empty-state,
wn-input, wn-textarea, wn-select { display: block; }
wn-toast-region { display: block; }
wn-toast { display: block; }
wn-sidebar-group, wn-sidebar-item { display: block; }
wn-table-row, wn-table-cell { display: contents; }
/* host hidden state — needed because shadow-DOM components don't inherit
* \`[hidden]\` semantics in light DOM. Lit's host attribute reflection
* handles attributes, but \`hidden\` on the host itself should still work. */
[hidden] { display: none !important; }
/* ====== Buttons ====== */
.wn-btn {
font: 500 13px var(--ff-sans);
letter-spacing: -0.005em;
padding: 9px 16px;
border-radius: var(--r-2);
border: 1px solid var(--border);
background: var(--paper);
color: var(--ink);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
text-decoration: none;
line-height: 1.2;
}
.wn-btn:hover { border-color: var(--ink); }
.wn-btn:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.wn-btn:active { background: var(--bg-3); }
.wn-btn[disabled], .wn-btn.is-disabled {
color: var(--ink-5); border-color: var(--border); cursor: not-allowed; background: var(--paper);
}
.wn-btn--primary { background: var(--ink); color: var(--paper); border-color: var(--ink); }
.wn-btn--primary:hover { background: var(--ink-2); border-color: var(--ink-2); }
.wn-btn--primary:active { background: var(--ink); }
.wn-btn--primary[disabled], .wn-btn--primary.is-disabled {
background: var(--ink-5); border-color: var(--ink-5); color: var(--paper);
}
.wn-btn--ghost { background: transparent; border-color: transparent; padding: 7px 10px; }
.wn-btn--ghost:hover { background: var(--bg-3); border-color: transparent; }
.wn-btn--danger { background: var(--paper); color: var(--ink); border-color: var(--ink); }
.wn-btn--sm { padding: 5px 10px; font-size: 12px; }
.wn-btn--lg { padding: 12px 20px; font-size: 14px; }
.wn-btn__icon { width: 14px; height: 14px; flex: none; }
.wn-btn--lg .wn-btn__icon { width: 16px; height: 16px; }
/* ====== Eyebrows & labels ====== */
.wn-eyebrow {
font: 500 11px/1.2 var(--ff-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--fg-3);
display: inline-block;
}
.wn-eyebrow--strong { color: var(--fg-1); }
/* ====== Tags ====== */
.wn-tag {
font: 500 10px/1 var(--ff-mono);
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 5px 10px;
border-radius: var(--r-pill);
border: 1px solid var(--border);
color: var(--fg-2);
background: var(--paper);
display: inline-block;
white-space: nowrap;
}
.wn-tag--active { background: var(--ink); color: var(--paper); border-color: var(--ink); }
.wn-tag--draft { background: var(--hi); color: var(--hi-ink); border-color: transparent; }
/* ====== Stage / Phase dots ====== */
.wn-dot {
display: inline-flex;
align-items: center;
gap: 6px;
font: 500 10px/1 var(--ff-mono);
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--fg-2);
}
.wn-dot__bullet { width: 8px; height: 8px; border-radius: 999px; background: var(--ink); flex: none; }
/* signal levels (S0S4) */
.wn-stage-dot--s0 .wn-dot__bullet { background: var(--status-raw); }
.wn-stage-dot--s1 .wn-dot__bullet { background: var(--status-weak); }
.wn-stage-dot--s2 .wn-dot__bullet { background: var(--status-medium); }
.wn-stage-dot--s3 .wn-dot__bullet { background: var(--status-strong); }
.wn-stage-dot--s4 .wn-dot__bullet { background: var(--status-commercial); }
/* phase states (todo / active / done / warn) — numbered phases, distinct from signal */
.wn-phase-dot__bullet {
width: 18px; height: 18px; border-radius: 999px;
border: 1px solid var(--border-strong);
background: var(--paper);
display: inline-flex; align-items: center; justify-content: center;
font: 500 10px/1 var(--ff-mono); color: var(--fg-3);
flex: none;
}
.wn-phase-dot--todo .wn-phase-dot__bullet { border-color: var(--border-strong); color: var(--fg-3); background: var(--paper); }
.wn-phase-dot--active .wn-phase-dot__bullet { border-color: var(--ink); color: var(--ink); background: var(--paper); box-shadow: 0 0 0 3px rgba(10,10,10,0.06); }
.wn-phase-dot--done .wn-phase-dot__bullet { border-color: var(--ink); color: var(--paper); background: var(--ink); }
.wn-phase-dot--warn .wn-phase-dot__bullet { border-color: var(--hi-2); color: var(--hi-ink); background: var(--hi); }
/* ====== Stamp ====== */
.wn-stamp {
display: inline-block;
background: var(--hi);
color: var(--hi-ink);
padding: 5px 10px 3px;
font: 500 10px/1 var(--ff-mono);
letter-spacing: 0.12em;
text-transform: uppercase;
transform: rotate(-1.5deg);
}
/* ====== Icon ====== */
.wn-icon { stroke-width: 1.5; stroke: currentColor; fill: none; display: inline-block; vertical-align: middle; }
.wn-icon--sm { width: 14px; height: 14px; }
.wn-icon--md { width: 16px; height: 16px; }
.wn-icon--lg { width: 20px; height: 20px; }
.wn-icon--xl { width: 24px; height: 24px; }
/* ====== Card ====== */
.wn-card {
background: var(--paper);
border: 1px solid var(--border);
border-radius: var(--r-2);
padding: var(--sp-5);
display: flex;
flex-direction: column;
gap: var(--sp-3);
position: relative;
}
.wn-card--inset { background: var(--paper-2); border-color: var(--border); }
.wn-card--recessed { background: var(--paper-3); }
.wn-card--lg { padding: var(--sp-6); border-radius: var(--r-3); }
.wn-card--sm { padding: var(--sp-4); gap: var(--sp-2); }
.wn-card--clickable { cursor: pointer; transition: border-color 120ms ease; }
.wn-card--clickable:hover { border-color: var(--ink); }
.wn-card--clickable:hover::before {
content: ""; position: absolute; left: -1px; top: -1px; bottom: -1px;
width: 2px; background: var(--ink); border-radius: 2px 0 0 2px;
}
.wn-card__head { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-3); }
.wn-card__title { font: 500 17px/1.35 var(--ff-sans); margin: 4px 0 8px; color: var(--fg-1); }
.wn-card__foot {
display: flex; justify-content: space-between; gap: var(--sp-3);
padding-top: var(--sp-3); margin-top: 4px;
border-top: 1px solid var(--border-soft);
font: 500 11px var(--ff-mono); letter-spacing: 0.06em; text-transform: uppercase;
color: var(--fg-3);
}
/* ====== Field row (label + value, 3-col grid) ====== */
.wn-field-row {
display: grid;
grid-template-columns: 200px 1fr auto;
gap: var(--sp-4) var(--sp-5);
padding: var(--sp-3) 0;
border-bottom: 1px solid var(--border-soft);
align-items: baseline;
}
.wn-field-row:last-child { border-bottom: 0; }
.wn-field-row__label {
font: 500 11px/1.5 var(--ff-mono);
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--fg-3);
}
.wn-field-row__value { font: 400 15px/1.55 var(--ff-sans); color: var(--fg-1); }
.wn-field-row__aside { font: 400 12px var(--ff-mono); color: var(--fg-3); text-align: right; }
.wn-field-row--stacked { grid-template-columns: 1fr; gap: 6px; }
.wn-field-row--narrow { grid-template-columns: 120px 1fr; }
/* ====== Form inputs ====== */
.wn-form-label {
font: 500 11px/1 var(--ff-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--fg-3);
display: block;
margin-bottom: 6px;
}
.wn-input, .wn-textarea, .wn-select {
font: 400 14px var(--ff-sans);
padding: 10px 12px;
border: 1px solid var(--border);
background: var(--paper);
border-radius: var(--r-1);
color: var(--fg-1);
outline: none;
width: 100%;
transition: border-color 120ms ease;
box-sizing: border-box;
}
.wn-input:hover, .wn-textarea:hover, .wn-select:hover { border-color: var(--border-strong); }
.wn-input:focus, .wn-textarea:focus, .wn-select:focus { border-color: var(--ink); }
.wn-input::placeholder, .wn-textarea::placeholder { color: var(--ink-5); }
.wn-input[disabled], .wn-textarea[disabled], .wn-select[disabled] {
background: var(--paper-2); color: var(--fg-3); cursor: not-allowed;
}
.wn-textarea { resize: vertical; min-height: 96px; font-family: var(--ff-sans); }
.wn-select {
appearance: none; -webkit-appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6' fill='none' stroke='%235C5C5C' stroke-width='1.5'><path d='M1 1l4 4 4-4'/></svg>");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 32px;
}
.wn-input--error, .wn-textarea--error, .wn-select--error {
border-color: var(--ink); border-bottom-width: 2px;
}
.wn-form-help { font: 400 11px var(--ff-mono); color: var(--fg-3); margin-top: 6px; display: block; }
.wn-form-error { font: 400 11px var(--ff-mono); color: var(--ink); margin-top: 6px; display: block; }
/* Search input — extracted from TopNav, also usable standalone */
.wn-search {
display: inline-flex;
align-items: center;
gap: 10px;
border: 1px solid var(--border);
padding: 6px 10px;
border-radius: var(--r-1);
background: var(--paper);
color: var(--fg-3);
font: 400 12px var(--ff-mono);
min-width: 200px;
transition: border-color 120ms ease;
}
.wn-search:focus-within { border-color: var(--ink); }
.wn-search input {
border: 0; outline: 0; background: none; flex: 1;
font: inherit; color: var(--fg-1); padding: 0;
}
.wn-search input::placeholder { color: var(--ink-5); }
.wn-search__kbd {
padding: 1px 5px;
border: 1px solid var(--border);
border-radius: 2px;
font-size: 10px;
color: var(--fg-3);
}
/* ====== Breadcrumb ====== */
.wn-breadcrumb {
display: flex; flex-wrap: wrap; align-items: center;
gap: 6px;
font: 400 12px/1.5 var(--ff-mono);
color: var(--fg-3);
margin-bottom: var(--sp-4);
}
.wn-breadcrumb a {
color: var(--fg-2);
text-decoration: none;
padding: 2px 0;
border-bottom: 1px solid transparent;
transition: border-color 120ms ease, color 120ms ease;
}
.wn-breadcrumb a:hover { color: var(--fg-1); border-bottom-color: var(--border-strong); }
.wn-breadcrumb__sep { color: var(--ink-5); user-select: none; }
.wn-breadcrumb__current { color: var(--fg-1); }
/* ====== Modal / Dialog ====== */
.wn-modal__backdrop {
position: fixed; inset: 0;
background: rgba(10, 10, 10, 0.40);
display: flex; align-items: center; justify-content: center;
z-index: 100;
padding: var(--sp-5);
}
.wn-modal__panel {
background: var(--paper);
border-radius: var(--r-3);
box-shadow: var(--shadow-3);
max-width: 560px; width: 100%;
max-height: calc(100vh - 64px);
display: flex; flex-direction: column;
overflow: hidden;
}
.wn-modal__head {
padding: var(--sp-5) var(--sp-6) var(--sp-4);
border-bottom: 1px solid var(--border);
display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4);
}
.wn-modal__title { font: 500 20px/1.25 var(--ff-sans); margin: 0; color: var(--fg-1); }
.wn-modal__close {
background: none; border: 0; cursor: pointer; padding: 4px;
color: var(--fg-3); border-radius: var(--r-1);
transition: color 120ms ease;
}
.wn-modal__close:hover { color: var(--fg-1); }
.wn-modal__body {
padding: var(--sp-5) var(--sp-6);
overflow-y: auto;
flex: 1;
font: 400 15px/1.6 var(--ff-sans);
color: var(--fg-1);
}
.wn-modal__foot {
padding: var(--sp-4) var(--sp-6) var(--sp-5);
border-top: 1px solid var(--border);
display: flex; justify-content: flex-end; gap: var(--sp-2);
}
/* ====== Table ======
* Note: shadow-DOM-rendered rows can't be children of a real <table> (the
* HTML table model rejects unknown elements between <table> and <tr>). The
* <wn-table> component therefore renders a CSS-grid imitation. For real
* <table> markup (Django QuerySet rendering, etc.) use these classes
* directly on <table>/<tr>/<td> elements — see also the .wn-table--native
* variant below.
*/
/* CSS-grid imitation (default <wn-table>) */
.wn-table {
width: 100%;
font-size: var(--fs-sm);
display: flex;
flex-direction: column;
}
.wn-table__thead { border-bottom: 1px solid var(--border); }
.wn-table__tbody { display: flex; flex-direction: column; }
.wn-table__tr {
display: grid;
gap: var(--sp-4);
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border-soft);
align-items: baseline;
}
.wn-table__tr:last-child { border-bottom: 0; }
.wn-table__tr--head { border-bottom: 0; padding: var(--sp-3) var(--sp-4); }
.wn-table__th {
font: 500 11px/1.2 var(--ff-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--fg-3);
}
.wn-table__td {
color: var(--fg-1);
line-height: 1.5;
font-size: var(--fs-sm);
}
.wn-table--compact .wn-table__tr { padding: var(--sp-2) var(--sp-3); }
.wn-table__cell--mono { font-family: var(--ff-mono); color: var(--fg-2); font-size: 12px; }
.wn-table__cell--meta { color: var(--fg-3); font: 400 12px var(--ff-mono); }
.wn-table__cell--right { text-align: right; }
/* Native <table> variant — for Django QuerySet rendering etc. */
.wn-table--native {
border-collapse: collapse;
display: table;
}
.wn-table--native thead th {
text-align: left;
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border);
font: 500 11px/1.2 var(--ff-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--fg-3);
}
.wn-table--native tbody td {
padding: var(--sp-4);
border-bottom: 1px solid var(--border-soft);
vertical-align: top;
color: var(--fg-1);
font-size: var(--fs-sm);
line-height: 1.5;
}
.wn-table--native tbody tr:hover { background: var(--paper-2); }
.wn-table--native tbody tr:last-child td { border-bottom: 0; }
/* ====== Banner / Toast (success / info / warn) ====== */
.wn-banner {
display: flex;
align-items: flex-start;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
border: 1px solid var(--border);
background: var(--paper);
border-radius: var(--r-2);
font: 400 14px/1.5 var(--ff-sans);
color: var(--fg-1);
position: relative;
}
.wn-banner__icon { color: var(--fg-2); flex: none; padding-top: 2px; }
.wn-banner__body { flex: 1; }
.wn-banner__title {
font: 500 11px/1.2 var(--ff-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--fg-3);
margin: 0 0 4px;
}
.wn-banner__dismiss {
background: none; border: 0; cursor: pointer;
color: var(--fg-3); padding: 4px;
}
.wn-banner__dismiss:hover { color: var(--fg-1); }
.wn-banner--success { border-left: 2px solid var(--ink); }
.wn-banner--warn { border-left: 2px solid var(--hi-2); background: #FFFCEB; }
.wn-banner--error { border-left: 2px solid var(--ink); background: var(--paper); }
.wn-banner--info { border-left: 2px solid var(--border-strong); }
.wn-toast-region {
position: fixed;
bottom: var(--sp-5); right: var(--sp-5);
display: flex; flex-direction: column; gap: var(--sp-2);
z-index: 200;
max-width: 380px;
}
.wn-toast { box-shadow: var(--shadow-3); }
/* ====== Empty state ====== */
.wn-empty {
border: 1px dashed var(--border-strong);
border-radius: var(--r-2);
padding: var(--sp-7);
display: flex; flex-direction: column; align-items: center;
gap: var(--sp-2);
text-align: center;
color: var(--fg-3);
}
.wn-empty__icon { color: var(--fg-3); margin-bottom: var(--sp-2); }
.wn-empty__title { font: 500 14px var(--ff-sans); color: var(--fg-2); margin: 0; }
.wn-empty__body { font: 400 13px/1.5 var(--ff-sans); color: var(--fg-3); max-width: 40ch; margin: 0; }
.wn-empty__cta { margin-top: var(--sp-2); }
/* ====== Top navigation ====== */
.wn-topnav {
height: 56px;
background: rgba(255, 255, 255, 0.92);
border-bottom: 1px solid var(--border);
display: flex; align-items: center;
gap: var(--sp-6);
padding: 0 var(--sp-5);
position: sticky; top: 0; z-index: 10;
}
.wn-topnav__brand { display: flex; align-items: center; gap: 10px; font: 500 14px var(--ff-sans); }
.wn-topnav__brand img { width: 22px; height: 22px; }
.wn-topnav__brand-slug { font-family: var(--ff-mono); font-size: 12px; color: var(--fg-3); letter-spacing: 0.04em; }
.wn-topnav__links { display: flex; gap: 22px; }
.wn-topnav__link {
font: 500 13px var(--ff-sans);
color: var(--fg-2);
text-decoration: none;
padding: 6px 0;
border-bottom: 1px solid transparent;
transition: color 120ms ease, border-color 120ms ease;
}
.wn-topnav__link:hover { color: var(--fg-1); }
.wn-topnav__link--active { color: var(--fg-1); border-bottom-color: var(--ink); }
.wn-topnav__right { margin-left: auto; display: flex; align-items: center; gap: var(--sp-3); }
/* ====== Sidebar ====== */
.wn-sidebar {
width: 240px;
flex: none;
background: var(--paper-2);
border-right: 1px solid var(--border);
padding: var(--sp-5) var(--sp-4);
display: flex; flex-direction: column; gap: var(--sp-5);
height: calc(100vh - 56px);
position: sticky; top: 56px;
overflow-y: auto;
}
.wn-sidebar__group { display: flex; flex-direction: column; gap: 8px; }
.wn-sidebar__group-label { padding-left: 12px; }
.wn-sidebar__item {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px;
border-radius: 4px;
color: var(--fg-2);
font: 500 13px var(--ff-sans);
cursor: pointer; text-decoration: none;
transition: background 120ms ease, color 120ms ease;
}
.wn-sidebar__item:hover { color: var(--fg-1); }
.wn-sidebar__item--active {
color: var(--fg-1); background: var(--paper);
box-shadow: 0 0 0 1px var(--border) inset;
}
.wn-sidebar__item--doc { font-family: var(--ff-mono); font-size: 12px; }
.wn-sidebar__count { margin-left: auto; font: 400 11px var(--ff-mono); color: var(--fg-3); }
.wn-sidebar__footer { margin-top: auto; padding-top: var(--sp-3); border-top: 1px solid var(--border); }
.wn-sidebar__activation {
display: flex; align-items: center; gap: 8px; padding: 6px 12px;
font: 500 11px var(--ff-mono); letter-spacing: 0.06em; text-transform: uppercase;
color: var(--fg-2);
}
.wn-sidebar__activation-dot { width: 6px; height: 6px; border-radius: 999px; background: var(--hi-2); }
/* ====== Page header ====== */
.wn-page-header {
margin-bottom: var(--sp-6);
display: flex; flex-direction: column; gap: 8px;
}
.wn-page-header__row { display: flex; align-items: flex-end; gap: var(--sp-5); }
.wn-page-header__title {
font: 500 32px/1.15 var(--ff-sans);
letter-spacing: -0.015em;
margin: 0; flex: 1; color: var(--fg-1);
}
.wn-page-header__actions { display: flex; gap: 8px; flex-wrap: wrap; }
.wn-page-header__lede {
font: 400 16px/1.55 var(--ff-sans);
color: var(--fg-2);
margin: 0;
max-width: 60ch;
}
/* ====== Pipeline ====== */
.wn-pipeline {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0;
position: relative;
margin: 0 0 var(--sp-6);
}
.wn-pipeline__stage {
padding: 10px 12px 14px;
border-top: 2px solid var(--border);
display: flex; flex-direction: column; gap: 4px;
position: relative;
}
.wn-pipeline__stage--done { border-top-color: var(--ink); }
.wn-pipeline__stage--active { border-top-color: var(--hi-2); }
.wn-pipeline__num {
font: 500 10px/1 var(--ff-mono); letter-spacing: 0.1em; text-transform: uppercase;
color: var(--fg-3);
}
.wn-pipeline__stage--done .wn-pipeline__num,
.wn-pipeline__stage--active .wn-pipeline__num { color: var(--fg-1); }
.wn-pipeline__name { font: 500 14px/1.25 var(--ff-sans); color: var(--fg-1); }
.wn-pipeline__stage--pending .wn-pipeline__name { color: var(--fg-3); }
.wn-pipeline__meta { font: 400 11px/1.35 var(--ff-mono); color: var(--fg-3); }
.wn-pipeline__arrow {
position: absolute; top: -8px; right: -7px;
font: 400 14px var(--ff-mono); color: var(--ink-5);
}
.wn-pipeline__stage--done .wn-pipeline__arrow,
.wn-pipeline__stage--active .wn-pipeline__arrow { color: var(--ink); }
/* ====== Prototype card (combined card variant) ====== */
.wn-prototype-card { /* extends .wn-card */ }
.wn-prototype-card__qrow {
display: grid; grid-template-columns: 110px 1fr; gap: 6px 12px;
font-size: 13px; color: var(--fg-1);
}
.wn-prototype-card__qkey {
font: 500 11px/1.5 var(--ff-mono);
letter-spacing: 0.06em; text-transform: uppercase;
color: var(--fg-3);
}
.wn-prototype-card__qval { line-height: 1.45; }
/* ====== Layout helpers ====== */
.wn-main { padding: 40px 48px 80px; max-width: 1180px; }
.wn-app { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }
`;
let _sheet = null;
export function getSharedSheet() {
if (!_sheet) {
_sheet = new CSSStyleSheet();
_sheet.replaceSync(SHARED_CSS);
}
return _sheet;
}

View File

@@ -0,0 +1,164 @@
/* =============================================================
* @whynot/design — atoms.js
* ------------------------------------------------------------
* <wn-button>, <wn-tag>, <wn-eyebrow>, <wn-stamp>,
* <wn-stage-dot>, <wn-phase-dot>, <wn-icon>
*
* Shadow-DOM components. Each adopts the shared component
* stylesheet so utility classes inside the shadow root work.
* Token CSS variables cascade through shadow boundaries
* because they're inherited properties.
* ============================================================= */
import { LitElement, html, nothing } from "lit";
import { getSharedSheet } from "./_styles.js";
import { ICON_PATHS } from "./icons.js";
class WnBase extends LitElement {
static styles = [];
connectedCallback() {
super.connectedCallback();
// Adopt the shared sheet on first connect, after super() has built the shadow root.
const root = this.shadowRoot;
if (root && !root.adoptedStyleSheets.includes(getSharedSheet())) {
root.adoptedStyleSheets = [...root.adoptedStyleSheets, getSharedSheet()];
}
}
}
/* ---------- <wn-button> ---------- */
export class WnButton extends WnBase {
static properties = {
variant: { type: String, reflect: true },
size: { type: String, reflect: true },
icon: { type: String },
iconEnd: { type: String, attribute: "icon-end" },
type: { type: String },
disabled: { type: Boolean, reflect: true },
href: { type: String },
};
constructor() {
super();
this.variant = "secondary";
this.size = "md";
this.type = "button";
this.disabled = false;
}
render() {
const cls = [
"wn-btn",
this.variant && this.variant !== "secondary" ? `wn-btn--${this.variant}` : "",
this.size === "sm" ? "wn-btn--sm" : this.size === "lg" ? "wn-btn--lg" : "",
].filter(Boolean).join(" ");
const iconStart = this.icon
? html`<wn-icon name=${this.icon} size="sm" class="wn-btn__icon"></wn-icon>`
: nothing;
const iconEnd = this.iconEnd
? html`<wn-icon name=${this.iconEnd} size="sm" class="wn-btn__icon"></wn-icon>`
: nothing;
if (this.href) {
return html`<a class=${cls} href=${this.href} part="button"
aria-disabled=${this.disabled ? "true" : "false"}>${iconStart}<slot></slot>${iconEnd}</a>`;
}
return html`<button class=${cls} part="button"
type=${this.type} ?disabled=${this.disabled}>${iconStart}<slot></slot>${iconEnd}</button>`;
}
}
/* ---------- <wn-tag> ---------- */
export class WnTag extends WnBase {
static properties = {
active: { type: Boolean, reflect: true },
draft: { type: Boolean, reflect: true },
};
render() {
const cls = ["wn-tag",
this.active ? "wn-tag--active" : "",
this.draft ? "wn-tag--draft" : "",
].filter(Boolean).join(" ");
return html`<span class=${cls} part="tag"><slot></slot></span>`;
}
}
/* ---------- <wn-eyebrow> ---------- */
export class WnEyebrow extends WnBase {
static properties = { strong: { type: Boolean, reflect: true } };
render() {
const cls = "wn-eyebrow" + (this.strong ? " wn-eyebrow--strong" : "");
return html`<span class=${cls} part="eyebrow"><slot></slot></span>`;
}
}
/* ---------- <wn-stamp> ---------- */
export class WnStamp extends WnBase {
render() { return html`<span class="wn-stamp" part="stamp"><slot></slot></span>`; }
}
/* ---------- <wn-stage-dot> ---------- */
export class WnStageDot extends WnBase {
static properties = {
level: { type: String, reflect: true },
label: { type: String },
};
constructor() { super(); this.level = "S2"; }
render() {
const lvl = String(this.level || "S2").toLowerCase();
const cls = `wn-dot wn-stage-dot wn-stage-dot--${lvl}`;
return html`
<span class=${cls} part="root">
<span class="wn-dot__bullet"></span>
<slot>${this.label || this.level}</slot>
</span>`;
}
}
/* ---------- <wn-phase-dot> ---------- */
export class WnPhaseDot extends WnBase {
static properties = {
state: { type: String, reflect: true },
num: { type: String, reflect: true },
};
constructor() { super(); this.state = "todo"; this.num = ""; }
render() {
const cls = `wn-phase-dot wn-phase-dot--${this.state}`;
const glyph = this.state === "done" ? "✓" : this.num;
return html`
<span class=${cls} part="root">
<span class="wn-phase-dot__bullet">${glyph}</span>
<slot></slot>
</span>`;
}
}
/* ---------- <wn-icon> ---------- */
export class WnIcon extends WnBase {
static properties = {
name: { type: String, reflect: true },
size: { type: String, reflect: true },
};
constructor() { super(); this.size = "md"; }
render() {
const path = ICON_PATHS[this.name];
const cls = `wn-icon wn-icon--${this.size || "md"}`;
if (!path) {
return html`<svg class=${cls} viewBox="0 0 24 24" aria-hidden="true" part="svg">
<rect x="3" y="3" width="18" height="18" rx="2" fill="none" stroke="currentColor"/>
</svg>`;
}
return html`<svg class=${cls} viewBox="0 0 24 24" aria-hidden="true" part="svg">${
path.map(d => html`<path d=${d}></path>`)
}</svg>`;
}
}
export function defineAtoms() {
if (!customElements.get("wn-button")) customElements.define("wn-button", WnButton);
if (!customElements.get("wn-tag")) customElements.define("wn-tag", WnTag);
if (!customElements.get("wn-eyebrow")) customElements.define("wn-eyebrow", WnEyebrow);
if (!customElements.get("wn-stamp")) customElements.define("wn-stamp", WnStamp);
if (!customElements.get("wn-stage-dot")) customElements.define("wn-stage-dot", WnStageDot);
if (!customElements.get("wn-phase-dot")) customElements.define("wn-phase-dot", WnPhaseDot);
if (!customElements.get("wn-icon")) customElements.define("wn-icon", WnIcon);
}
export { WnBase };

View File

@@ -0,0 +1,206 @@
/* =============================================================
* @whynot/design — chrome.js
* ------------------------------------------------------------
* <wn-top-nav>, <wn-sidebar> / <wn-sidebar-group> /
* <wn-sidebar-item>, <wn-page-header>, <wn-pipeline>,
* <wn-prototype-card>
* ============================================================= */
import { LitElement, html, nothing } from "lit";
import { WnBase } from "./atoms.js";
/* ---------- <wn-top-nav> ---------- */
export class WnTopNav extends WnBase {
static properties = {
logoSrc: { type: String, attribute: "logo-src" },
brand: { type: String },
slug: { type: String },
};
constructor() { super(); this.brand = "whynot"; this.slug = "control"; }
render() {
return html`
<nav class="wn-topnav" part="nav">
<div class="wn-topnav__brand">
${this.logoSrc ? html`<img src=${this.logoSrc} alt="">` : nothing}
<span>${this.brand}</span>
${this.slug ? html`<span class="wn-topnav__brand-slug">/ ${this.slug}</span>` : nothing}
</div>
<div class="wn-topnav__links"><slot name="links"></slot></div>
<div class="wn-topnav__right"><slot name="right"></slot></div>
</nav>
`;
}
}
/* ---------- <wn-sidebar> ---------- */
export class WnSidebar extends WnBase {
static properties = { activation: { type: String } };
render() {
return html`
<aside class="wn-sidebar" part="sidebar">
<slot></slot>
${this.activation
? html`<div class="wn-sidebar__footer">
<div class="wn-sidebar__activation">
<span class="wn-sidebar__activation-dot"></span>
<span>${this.activation}</span>
</div>
</div>`
: nothing}
</aside>
`;
}
}
export class WnSidebarGroup extends WnBase {
static properties = { label: { type: String } };
render() {
return html`
<div class="wn-sidebar__group" part="group">
${this.label ? html`<wn-eyebrow class="wn-sidebar__group-label">${this.label}</wn-eyebrow>` : nothing}
<slot></slot>
</div>
`;
}
}
export class WnSidebarItem extends WnBase {
static properties = {
href: { type: String },
icon: { type: String },
active: { type: Boolean, reflect: true },
count: { type: String },
variant: { type: String, reflect: true },
};
render() {
const cls = ["wn-sidebar__item",
this.active ? "wn-sidebar__item--active" : "",
this.variant === "doc" ? "wn-sidebar__item--doc" : "",
].filter(Boolean).join(" ");
const inner = html`
${this.icon ? html`<wn-icon name=${this.icon} size=${this.variant === "doc" ? "sm" : "md"}></wn-icon>` : nothing}
<slot></slot>
${this.count ? html`<span class="wn-sidebar__count">${this.count}</span>` : nothing}
`;
return this.href
? html`<a class=${cls} href=${this.href} part="item">${inner}</a>`
: html`<div class=${cls} part="item">${inner}</div>`;
}
}
/* ---------- <wn-page-header> ---------- */
export class WnPageHeader extends WnBase {
static properties = {
eyebrow: { type: String },
title: { type: String },
lede: { type: String },
hasActions: { state: true },
};
constructor() { super(); this.hasActions = false; }
_onSlot() { this.hasActions = !!this.querySelector('[slot="actions"]'); }
render() {
return html`
<header class="wn-page-header" part="root">
${this.eyebrow ? html`<wn-eyebrow>${this.eyebrow}</wn-eyebrow>` : nothing}
<slot name="eyebrow"></slot>
<div class="wn-page-header__row">
<h1 class="wn-page-header__title">${this.title}<slot name="title"></slot></h1>
<div class="wn-page-header__actions" ?hidden=${!this.hasActions}>
<slot name="actions" @slotchange=${this._onSlot}></slot>
</div>
</div>
${this.lede ? html`<p class="wn-page-header__lede">${this.lede}</p>` : nothing}
<slot name="lede"></slot>
</header>
`;
}
}
/* ---------- <wn-pipeline> ---------- */
export class WnPipeline extends WnBase {
static properties = {
stages: { type: Array },
activeIdx: { type: Number, attribute: "active-idx" },
};
constructor() {
super();
this.stages = [
{ num: "Stage 0", name: "Raw idea", meta: "inbox/" },
{ num: "Stage 1", name: "Triage", meta: "" },
{ num: "Stage 2", name: "Prototype card", meta: "prototypes/" },
{ num: "Stage 3", name: "Experiment", meta: "" },
{ num: "Stage 4", name: "Signal review", meta: "" },
];
this.activeIdx = 0;
}
render() {
return html`
<div class="wn-pipeline" part="root">
${this.stages.map((s, i) => {
const state = i < this.activeIdx ? "done" : i === this.activeIdx ? "active" : "pending";
const cls = `wn-pipeline__stage wn-pipeline__stage--${state}`;
return html`
<div class=${cls}>
<span class="wn-pipeline__num">${s.num}</span>
<span class="wn-pipeline__name">${s.name}</span>
${s.meta ? html`<span class="wn-pipeline__meta">${s.meta}</span>` : nothing}
${i > 0 ? html`<span class="wn-pipeline__arrow">→</span>` : nothing}
</div>
`;
})}
</div>
`;
}
}
/* ---------- <wn-prototype-card> ---------- */
export class WnPrototypeCard extends WnBase {
static properties = {
cardId: { type: String, attribute: "card-id", reflect: true },
signal: { type: String, reflect: true },
stageLabel: { type: String, attribute: "stage-label" },
href: { type: String },
};
constructor() { super(); this.signal = "S1"; }
_onClick() {
if (this.href) window.location.href = this.href;
this.dispatchEvent(new CustomEvent("wn-open", { detail: { id: this.cardId }, bubbles: true, composed: true }));
}
render() {
const clickable = !!this.href;
const cls = "wn-card wn-prototype-card" + (clickable ? " wn-card--clickable" : "");
return html`
<article class=${cls}
role=${clickable ? "button" : nothing}
tabindex=${clickable ? "0" : nothing}
@click=${clickable ? this._onClick : nothing}
part="card">
<header class="wn-card__head">
<wn-eyebrow>${this.cardId ? this.cardId + " · " : ""}Prototype</wn-eyebrow>
<wn-stage-dot level=${this.signal}>${this.stageLabel || this.signal}</wn-stage-dot>
</header>
<h3 class="wn-card__title"><slot name="pitch"></slot></h3>
<div class="wn-prototype-card__qrow">
<span class="wn-prototype-card__qkey">Learning q.</span>
<span class="wn-prototype-card__qval"><slot name="learning"></slot></span>
<span class="wn-prototype-card__qkey">Smallest test</span>
<span class="wn-prototype-card__qval"><slot name="test"></slot></span>
</div>
<footer class="wn-card__foot">
<span><slot name="target"></slot></span>
<span>${this.signal} signal</span>
</footer>
</article>
`;
}
}
export function defineChrome() {
if (!customElements.get("wn-top-nav")) customElements.define("wn-top-nav", WnTopNav);
if (!customElements.get("wn-sidebar")) customElements.define("wn-sidebar", WnSidebar);
if (!customElements.get("wn-sidebar-group")) customElements.define("wn-sidebar-group", WnSidebarGroup);
if (!customElements.get("wn-sidebar-item")) customElements.define("wn-sidebar-item", WnSidebarItem);
if (!customElements.get("wn-page-header")) customElements.define("wn-page-header", WnPageHeader);
if (!customElements.get("wn-pipeline")) customElements.define("wn-pipeline", WnPipeline);
if (!customElements.get("wn-prototype-card")) customElements.define("wn-prototype-card", WnPrototypeCard);
}

View File

@@ -0,0 +1,205 @@
/* =============================================================
* @whynot/design — form.js
* ------------------------------------------------------------
* <wn-input>, <wn-textarea>, <wn-select>,
* <wn-search-input>, <wn-field-row>
*
* Each wraps a real native element. Form participation works
* because the native input is part of the light DOM via the
* `name` attribute being copied through; for richer integration
* use ElementInternals (deferred — see CHANGELOG).
* ============================================================= */
import { LitElement, html, nothing } from "lit";
import { WnBase } from "./atoms.js";
/* ---------- <wn-input> ---------- */
export class WnInput extends WnBase {
static properties = {
name: { type: String, reflect: true },
type: { type: String, reflect: true },
value: { type: String },
placeholder: { type: String },
required: { type: Boolean, reflect: true },
disabled: { type: Boolean, reflect: true },
readonly: { type: Boolean, reflect: true },
autocomplete:{ type: String },
error: { type: Boolean, reflect: true },
help: { type: String },
errorText: { type: String, attribute: "error-text" },
};
constructor() {
super();
this.type = "text";
this.value = "";
this.required = false;
this.disabled = false;
this.readonly = false;
this.error = false;
}
_onInput(e) {
this.value = e.target.value;
this.dispatchEvent(new CustomEvent("wn-input", { detail: { value: this.value }, bubbles: true, composed: true }));
}
render() {
const cls = "wn-input" + (this.error ? " wn-input--error" : "");
return html`
<input class=${cls}
part="input"
name=${this.name ?? nothing}
type=${this.type}
.value=${this.value}
placeholder=${this.placeholder ?? nothing}
?required=${this.required}
?disabled=${this.disabled}
?readonly=${this.readonly}
autocomplete=${this.autocomplete ?? nothing}
@input=${this._onInput}>
${this.error && this.errorText
? html`<span class="wn-form-error">${this.errorText}</span>`
: this.help
? html`<span class="wn-form-help">${this.help}</span>`
: nothing}
`;
}
}
/* ---------- <wn-textarea> ---------- */
export class WnTextarea extends WnBase {
static properties = {
name: { type: String, reflect: true },
value: { type: String },
placeholder: { type: String },
rows: { type: Number },
required: { type: Boolean, reflect: true },
disabled: { type: Boolean, reflect: true },
error: { type: Boolean, reflect: true },
help: { type: String },
errorText: { type: String, attribute: "error-text" },
};
constructor() { super(); this.value = ""; this.rows = 4; }
_onInput(e) {
this.value = e.target.value;
this.dispatchEvent(new CustomEvent("wn-input", { detail: { value: this.value }, bubbles: true, composed: true }));
}
render() {
const cls = "wn-textarea" + (this.error ? " wn-textarea--error" : "");
return html`
<textarea class=${cls}
part="textarea"
name=${this.name ?? nothing}
rows=${this.rows}
placeholder=${this.placeholder ?? nothing}
?required=${this.required}
?disabled=${this.disabled}
@input=${this._onInput}
.value=${this.value}></textarea>
${this.error && this.errorText
? html`<span class="wn-form-error">${this.errorText}</span>`
: this.help
? html`<span class="wn-form-help">${this.help}</span>`
: nothing}
`;
}
}
/* ---------- <wn-select> ----------
* Slot <option> elements; they're cloned into the inner <select>. */
export class WnSelect extends WnBase {
static properties = {
name: { type: String, reflect: true },
value: { type: String },
required: { type: Boolean, reflect: true },
disabled: { type: Boolean, reflect: true },
error: { type: Boolean, reflect: true },
help: { type: String },
};
_onSlotChange(e) {
const slot = e.target;
const select = this.shadowRoot?.querySelector("select.wn-select");
if (!select) return;
const options = slot.assignedElements({ flatten: true }).filter(el => el.tagName === "OPTION");
select.innerHTML = "";
for (const opt of options) select.appendChild(opt.cloneNode(true));
if (this.value != null) select.value = this.value;
}
_onChange(e) {
this.value = e.target.value;
this.dispatchEvent(new CustomEvent("wn-change", { detail: { value: this.value }, bubbles: true, composed: true }));
}
render() {
const cls = "wn-select" + (this.error ? " wn-select--error" : "");
return html`
<select class=${cls}
part="select"
name=${this.name ?? nothing}
?required=${this.required}
?disabled=${this.disabled}
@change=${this._onChange}></select>
<slot @slotchange=${this._onSlotChange} style="display:none"></slot>
${this.help ? html`<span class="wn-form-help">${this.help}</span>` : nothing}
`;
}
}
/* ---------- <wn-search-input> ---------- */
export class WnSearchInput extends WnBase {
static properties = {
placeholder: { type: String },
kbd: { type: String },
value: { type: String },
name: { type: String, reflect: true },
};
constructor() { super(); this.placeholder = "Search…"; this.kbd = "⌘ K"; this.value = ""; }
_onInput(e) {
this.value = e.target.value;
this.dispatchEvent(new CustomEvent("wn-input", { detail: { value: this.value }, bubbles: true, composed: true }));
}
render() {
return html`
<label class="wn-search" part="root">
<wn-icon name="search" size="sm"></wn-icon>
<input type="search"
name=${this.name ?? nothing}
.value=${this.value}
placeholder=${this.placeholder}
@input=${this._onInput}>
${this.kbd ? html`<span class="wn-search__kbd">${this.kbd}</span>` : nothing}
</label>
`;
}
}
/* ---------- <wn-field-row> ---------- */
export class WnFieldRow extends WnBase {
static properties = {
label: { type: String },
aside: { type: String },
stacked: { type: Boolean, reflect: true },
narrow: { type: Boolean, reflect: true },
htmlFor: { type: String, attribute: "for" },
};
render() {
const cls = ["wn-field-row",
this.stacked ? "wn-field-row--stacked" : "",
this.narrow ? "wn-field-row--narrow" : "",
].filter(Boolean).join(" ");
return html`
<div class=${cls} part="root">
<label class="wn-field-row__label" for=${this.htmlFor ?? nothing}>${this.label}</label>
<div class="wn-field-row__value"><slot></slot></div>
${this.aside
? html`<div class="wn-field-row__aside">${this.aside}<slot name="aside"></slot></div>`
: html`<div class="wn-field-row__aside"><slot name="aside"></slot></div>`}
</div>
`;
}
}
export function defineForm() {
if (!customElements.get("wn-input")) customElements.define("wn-input", WnInput);
if (!customElements.get("wn-textarea")) customElements.define("wn-textarea", WnTextarea);
if (!customElements.get("wn-select")) customElements.define("wn-select", WnSelect);
if (!customElements.get("wn-search-input")) customElements.define("wn-search-input", WnSearchInput);
if (!customElements.get("wn-field-row")) customElements.define("wn-field-row", WnFieldRow);
}

View File

@@ -0,0 +1,45 @@
/* =============================================================
* @whynot/design — icons.js
* ------------------------------------------------------------
* Inline Lucide-style icon paths (24×24, stroke 1.5, fill none).
* Only the icons used by the system ship here; consumers can
* extend by importing extras directly from `lucide`.
*
* Each value is an array of `d` attributes — multi-path icons
* are rendered as multiple <path> elements.
*
* Paths derived from Lucide (ISC). If you need an icon not in
* this list, add it here, not in a consuming repo.
* ============================================================= */
/* prettier-ignore */
export const ICON_PATHS = {
/* Navigation */
"inbox": ["M22 12h-6l-2 3h-4l-2-3H2", "M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"],
"lightbulb": ["M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5", "M9 18h6", "M10 22h4"],
"flask-conical": ["M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2", "M6.453 15h11.094", "M8.5 2h7"],
"activity": ["M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.5.5 0 0 1-.96 0L9.24 2.18a.5.5 0 0 0-.96 0l-2.35 8.36A2 2 0 0 1 4 12H2"],
"users": ["M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2", "M22 21v-2a4 4 0 0 0-3-3.87", "M16 3.13a4 4 0 0 1 0 7.75", "M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z"],
"git-branch": ["M6 3v12", "M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6z", "M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6z", "M15 6a9 9 0 0 0-9 9"],
"check-square": ["M9 11l3 3L22 4", "M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"],
"archive": ["M21 8v13H3V8", "M1 3h22v5H1z", "M10 12h4"],
"file-text": ["M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z", "M14 2v5h6", "M16 13H8", "M16 17H8", "M10 9H8"],
"folder": ["M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"],
/* Actions / signals */
"arrow-right": ["M5 12h14", "M12 5l7 7-7 7"],
"arrow-left": ["M19 12H5", "M12 19l-7-7 7-7"],
"plus": ["M12 5v14", "M5 12h14"],
"x": ["M18 6L6 18", "M6 6l12 12"],
"check": ["M20 6 9 17l-5-5"],
"search": ["M21 21l-4.34-4.34", "M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z"],
"filter": ["M22 3H2l8 9.46V19l4 2v-8.54L22 3z"],
"circle-help": ["M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z", "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3", "M12 17h.01"],
"circle-alert": ["M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z", "M12 8v4", "M12 16h.01"],
"circle-check": ["M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z", "M9 12l2 2 4-4"],
"circle-info": ["M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z", "M12 16v-4", "M12 8h.01"],
"settings": ["M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z", "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"],
"more-horizontal": ["M12 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2z", "M19 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2z", "M5 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"],
"chevron-down": ["M6 9l6 6 6-6"],
"chevron-right": ["M9 18l6-6-6-6"],
};

View File

@@ -0,0 +1,277 @@
/* =============================================================
* @whynot/design — layout.js
* ------------------------------------------------------------
* <wn-card>, <wn-modal>, <wn-table> / <wn-table-row> /
* <wn-table-cell>, <wn-banner>, <wn-toast>, <wn-toast-region>,
* <wn-empty-state>, <wn-breadcrumb>
* ============================================================= */
import { LitElement, html, nothing } from "lit";
import { WnBase } from "./atoms.js";
/* ---------- <wn-card> ---------- */
export class WnCard extends WnBase {
static properties = {
variant: { type: String, reflect: true },
size: { type: String, reflect: true },
clickable: { type: Boolean, reflect: true },
hasHeader: { state: true },
hasFooter: { state: true },
};
constructor() {
super();
this.hasHeader = false;
this.hasFooter = false;
}
_onSlotChange() {
this.hasHeader = !!this.querySelector('[slot="header"]');
this.hasFooter = !!this.querySelector('[slot="footer"]');
}
render() {
const cls = ["wn-card",
this.variant && this.variant !== "default" ? `wn-card--${this.variant}` : "",
this.size === "sm" ? "wn-card--sm" : this.size === "lg" ? "wn-card--lg" : "",
this.clickable ? "wn-card--clickable" : "",
].filter(Boolean).join(" ");
return html`
<div class=${cls}
role=${this.clickable ? "button" : nothing}
tabindex=${this.clickable ? "0" : nothing}
part="card">
<header class="wn-card__head" ?hidden=${!this.hasHeader}>
<slot name="header" @slotchange=${this._onSlotChange}></slot>
</header>
<slot @slotchange=${this._onSlotChange}></slot>
<footer class="wn-card__foot" ?hidden=${!this.hasFooter}>
<slot name="footer" @slotchange=${this._onSlotChange}></slot>
</footer>
</div>
`;
}
}
/* ---------- <wn-modal> ---------- */
export class WnModal extends WnBase {
static properties = {
open: { type: Boolean, reflect: true },
title: { type: String },
dismissible: { type: Boolean, reflect: true },
hasFooter: { state: true },
};
constructor() {
super();
this.open = false;
this.dismissible = true;
this.hasFooter = false;
}
_onSlotChange() {
this.hasFooter = !!this.querySelector('[slot="footer"]');
}
_onBackdrop(e) {
if (e.target === e.currentTarget && this.dismissible) this._dismiss();
}
_dismiss() {
this.open = false;
this.dispatchEvent(new CustomEvent("wn-dismiss", { bubbles: true, composed: true }));
}
_bindKey = (e) => {
if (e.key === "Escape" && this.dismissible && this.open) this._dismiss();
};
connectedCallback() { super.connectedCallback(); document.addEventListener("keydown", this._bindKey); }
disconnectedCallback() { document.removeEventListener("keydown", this._bindKey); super.disconnectedCallback(); }
render() {
if (!this.open) return nothing;
return html`
<div class="wn-modal__backdrop" @click=${this._onBackdrop} role="presentation" part="backdrop">
<div class="wn-modal__panel" role="dialog" aria-modal="true" aria-label=${this.title ?? nothing} part="panel">
<header class="wn-modal__head">
<h2 class="wn-modal__title">${this.title}<slot name="title"></slot></h2>
${this.dismissible
? html`<button class="wn-modal__close" type="button" aria-label="Close" @click=${this._dismiss}>
<wn-icon name="x" size="md"></wn-icon>
</button>`
: nothing}
</header>
<div class="wn-modal__body"><slot @slotchange=${this._onSlotChange}></slot></div>
<footer class="wn-modal__foot" ?hidden=${!this.hasFooter}>
<slot name="footer" @slotchange=${this._onSlotChange}></slot>
</footer>
</div>
</div>
`;
}
}
/* ---------- <wn-table>, <wn-table-row>, <wn-table-cell> ----------
* Tables in shadow DOM can't render real <table>/<tr> with slotted rows —
* the table model requires the row to be a child of <table>. So these
* components use CSS grid + flexbox to imitate a table visually. For real
* <table> + Django QuerySet rendering, write raw <table class="wn-table">
* markup directly using utility classes.
*/
export class WnTable extends WnBase {
static properties = {
columns: { type: Array },
compact: { type: Boolean, reflect: true },
};
constructor() { super(); this.columns = []; }
render() {
const cols = this.columns || [];
const cls = "wn-table" + (this.compact ? " wn-table--compact" : "");
return html`
<div class=${cls} part="table" role="table">
${cols.length
? html`<div class="wn-table__thead" role="rowgroup">
<div class="wn-table__tr wn-table__tr--head" role="row"
style=${`grid-template-columns: ${cols.map(c => (typeof c === "object" && c.width) ? `${c.width}px` : "1fr").join(" ")}`}>
${cols.map(c => html`<div class="wn-table__th" role="columnheader">${typeof c === "string" ? c : c.label}</div>`)}
</div>
</div>`
: nothing}
<div class="wn-table__tbody" role="rowgroup"
style=${cols.length
? `--wn-cols: ${cols.map(c => (typeof c === "object" && c.width) ? `${c.width}px` : "1fr").join(" ")}`
: nothing}>
<slot></slot>
</div>
</div>
`;
}
}
export class WnTableRow extends WnBase {
render() {
return html`<div class="wn-table__tr" role="row" part="row"
style="grid-template-columns: var(--wn-cols, repeat(auto-fit, minmax(80px, 1fr)));">
<slot></slot>
</div>`;
}
}
export class WnTableCell extends WnBase {
static properties = { variant: { type: String, reflect: true } };
render() {
const cls = "wn-table__td" + (this.variant ? ` wn-table__cell--${this.variant}` : "");
return html`<div class=${cls} role="cell" part="cell"><slot></slot></div>`;
}
}
/* ---------- <wn-banner> ---------- */
export class WnBanner extends WnBase {
static properties = {
variant: { type: String, reflect: true },
title: { type: String },
icon: { type: String },
dismissible: { type: Boolean, reflect: true },
};
constructor() { super(); this.variant = "info"; }
_dismiss() {
this.dispatchEvent(new CustomEvent("wn-dismiss", { bubbles: true, composed: true }));
this.remove();
}
render() {
const iconName = this.icon || ({
info: "circle-info", success: "circle-check",
warn: "circle-alert", error: "circle-alert",
}[this.variant]);
const cls = `wn-banner wn-banner--${this.variant}`;
return html`
<div class=${cls} role=${this.variant === "error" || this.variant === "warn" ? "alert" : "status"} part="banner">
${iconName ? html`<span class="wn-banner__icon"><wn-icon name=${iconName} size="md"></wn-icon></span>` : nothing}
<div class="wn-banner__body">
${this.title ? html`<p class="wn-banner__title">${this.title}</p>` : nothing}
<slot></slot>
</div>
${this.dismissible
? html`<button class="wn-banner__dismiss" type="button" aria-label="Dismiss" @click=${this._dismiss}>
<wn-icon name="x" size="sm"></wn-icon>
</button>`
: nothing}
</div>
`;
}
}
/* ---------- <wn-toast> / <wn-toast-region> ---------- */
export class WnToast extends WnBanner {
constructor() { super(); this.dismissible = true; }
render() {
const base = super.render();
return html`<div class="wn-toast" part="toast">${base}</div>`;
}
}
export class WnToastRegion extends WnBase {
render() {
return html`<div class="wn-toast-region" role="region" aria-label="Notifications" part="root">
<slot></slot>
</div>`;
}
}
/* ---------- <wn-empty-state> ---------- */
export class WnEmptyState extends WnBase {
static properties = {
icon: { type: String },
title: { type: String },
hasCta: { state: true },
};
constructor() { super(); this.hasCta = false; }
_onSlot() { this.hasCta = !!this.querySelector('[slot="cta"]'); }
render() {
return html`
<div class="wn-empty" part="empty">
${this.icon ? html`<wn-icon class="wn-empty__icon" name=${this.icon} size="lg"></wn-icon>` : nothing}
${this.title ? html`<p class="wn-empty__title">${this.title}</p>` : nothing}
<p class="wn-empty__body"><slot @slotchange=${this._onSlot}></slot></p>
<div class="wn-empty__cta" ?hidden=${!this.hasCta}>
<slot name="cta" @slotchange=${this._onSlot}></slot>
</div>
</div>
`;
}
}
/* ---------- <wn-breadcrumb> ---------- */
export class WnBreadcrumb extends WnBase {
_onSlot(e) {
const slot = e.target;
const items = slot.assignedElements({ flatten: true });
// Build the rendered tree: each item + a separator after it.
const wrapper = this.shadowRoot?.querySelector('.wn-breadcrumb__list');
if (!wrapper) return;
wrapper.querySelectorAll('.wn-breadcrumb__sep').forEach(s => s.remove());
items.forEach((el, i) => {
el.classList.toggle("wn-breadcrumb__current", i === items.length - 1);
if (i > 0) {
const sep = document.createElement("span");
sep.className = "wn-breadcrumb__sep";
sep.setAttribute("aria-hidden", "true");
sep.textContent = "/";
// Use light-DOM-relative insertion: items are still in light DOM,
// so DOM-order separators between them belong in light DOM too.
el.parentNode.insertBefore(sep, el);
}
});
}
render() {
return html`
<nav class="wn-breadcrumb" aria-label="Breadcrumb" part="root">
<span class="wn-breadcrumb__list"><slot @slotchange=${this._onSlot}></slot></span>
</nav>
`;
}
}
export function defineLayout() {
if (!customElements.get("wn-card")) customElements.define("wn-card", WnCard);
if (!customElements.get("wn-modal")) customElements.define("wn-modal", WnModal);
if (!customElements.get("wn-table")) customElements.define("wn-table", WnTable);
if (!customElements.get("wn-table-row")) customElements.define("wn-table-row", WnTableRow);
if (!customElements.get("wn-table-cell")) customElements.define("wn-table-cell", WnTableCell);
if (!customElements.get("wn-banner")) customElements.define("wn-banner", WnBanner);
if (!customElements.get("wn-toast")) customElements.define("wn-toast", WnToast);
if (!customElements.get("wn-toast-region")) customElements.define("wn-toast-region", WnToastRegion);
if (!customElements.get("wn-empty-state")) customElements.define("wn-empty-state", WnEmptyState);
if (!customElements.get("wn-breadcrumb")) customElements.define("wn-breadcrumb", WnBreadcrumb);
}

View File

@@ -0,0 +1,35 @@
/* =============================================================
* @whynot/design — entry point
* ------------------------------------------------------------
* Side-effect import that registers every <wn-*> custom element.
*
* import "@whynot/design";
*
* If you only need a subset, import the per-group files instead:
*
* import "@whynot/design/atoms";
* import "@whynot/design/form";
* import "@whynot/design/layout";
* import "@whynot/design/chrome";
*
* CSS is imported separately:
*
* import "@whynot/design/styles/colors_and_type.css";
* import "@whynot/design/styles/components.css";
* ============================================================= */
import { defineAtoms } from "./elements/atoms.js";
import { defineForm } from "./elements/form.js";
import { defineLayout } from "./elements/layout.js";
import { defineChrome } from "./elements/chrome.js";
defineAtoms();
defineForm();
defineLayout();
defineChrome();
// Re-export classes for consumers that want to extend or reference them.
export * from "./elements/atoms.js";
export * from "./elements/form.js";
export * from "./elements/layout.js";
export * from "./elements/chrome.js";

View File

@@ -0,0 +1,22 @@
{
"$schema": "https://design-tokens.github.io/community-group/format/",
"ink": { "value": "#0A0A0A", "type": "color", "comment": "Near-black. The only fill most of the time." },
"ink-2": { "value": "#1F1F1F", "type": "color" },
"ink-3": { "value": "#5C5C5C", "type": "color" },
"ink-4": { "value": "#8A8A8A", "type": "color" },
"ink-5": { "value": "#B5B5B3", "type": "color", "comment": "Placeholder text, wireframe labels." },
"line": { "value": "#E5E5E2", "type": "color", "comment": "Default 1px wireframe rule." },
"line-strong": { "value": "#C9C9C5", "type": "color" },
"line-soft": { "value": "#F0F0EC", "type": "color" },
"paper": { "value": "#FFFFFF", "type": "color" },
"paper-2": { "value": "#FAFAF7", "type": "color" },
"paper-3": { "value": "#F4F4EF", "type": "color" },
"hi": { "value": "#FFE14A", "type": "color", "comment": "Annotation yellow. Highlighter only, never a button fill." },
"hi-2": { "value": "#FFD400", "type": "color" },
"hi-ink": { "value": "#1A1500", "type": "color", "comment": "Text on yellow." },
"status-raw": { "value": "#B5B5B3", "type": "color", "comment": "S0 — no signal" },
"status-weak": { "value": "#8A8A8A", "type": "color", "comment": "S1 — weak signal" },
"status-medium": { "value": "#5C5C5C", "type": "color", "comment": "S2 — medium signal" },
"status-strong": { "value": "#0A0A0A", "type": "color", "comment": "S3 — strong signal" },
"status-commercial": { "value": "#FFD400", "type": "color", "comment": "S4 — commercial" }
}

View File

@@ -0,0 +1,6 @@
{
"comment": "Manifest pointing at the three token files. Source-of-truth for any future Style Dictionary build.",
"colors": "./colors.json",
"type": "./type.json",
"spacing": "./spacing.json"
}

View File

@@ -0,0 +1,28 @@
{
"$schema": "https://design-tokens.github.io/community-group/format/",
"spacing": {
"1": { "value": "4px", "type": "dimension" },
"2": { "value": "8px", "type": "dimension" },
"3": { "value": "12px", "type": "dimension" },
"4": { "value": "16px", "type": "dimension" },
"5": { "value": "24px", "type": "dimension" },
"6": { "value": "32px", "type": "dimension" },
"7": { "value": "48px", "type": "dimension" },
"8": { "value": "64px", "type": "dimension" },
"9": { "value": "96px", "type": "dimension" },
"10": { "value": "128px", "type": "dimension" }
},
"radius": {
"0": { "value": "0px", "type": "dimension" },
"1": { "value": "2px", "type": "dimension" },
"2": { "value": "4px", "type": "dimension" },
"3": { "value": "8px", "type": "dimension" },
"pill": { "value": "999px", "type": "dimension" }
},
"shadow": {
"0": { "value": "none", "type": "shadow" },
"1": { "value": "0 1px 0 #E5E5E2", "type": "shadow" },
"2": { "value": "0 1px 0 #C9C9C5", "type": "shadow" },
"3": { "value": "0 4px 12px -6px rgba(10,10,10,0.10)", "type": "shadow", "comment": "Floating elements only." }
}
}

View File

@@ -0,0 +1,33 @@
{
"$schema": "https://design-tokens.github.io/community-group/format/",
"family": {
"sans": { "value": "\"IBM Plex Sans\", ui-sans-serif, system-ui, sans-serif", "type": "fontFamily" },
"mono": { "value": "\"IBM Plex Mono\", ui-monospace, \"SF Mono\", Menlo, monospace", "type": "fontFamily" },
"serif": { "value": "\"IBM Plex Serif\", \"Iowan Old Style\", Georgia, serif", "type": "fontFamily" }
},
"size": {
"xs": { "value": "11px", "type": "dimension" },
"sm": { "value": "13px", "type": "dimension" },
"base": { "value": "15px", "type": "dimension" },
"md": { "value": "17px", "type": "dimension" },
"lg": { "value": "20px", "type": "dimension" },
"xl": { "value": "24px", "type": "dimension" },
"2xl": { "value": "32px", "type": "dimension" },
"3xl": { "value": "44px", "type": "dimension" },
"4xl": { "value": "64px", "type": "dimension" },
"5xl": { "value": "96px", "type": "dimension" }
},
"lineHeight": {
"tight": { "value": 1.05, "type": "number" },
"snug": { "value": 1.25, "type": "number" },
"base": { "value": 1.5, "type": "number" },
"loose": { "value": 1.7, "type": "number" }
},
"tracking": {
"tight": { "value": "-0.02em", "type": "dimension" },
"snug": { "value": "-0.01em", "type": "dimension" },
"base": { "value": "0em", "type": "dimension" },
"mono": { "value": "0.02em", "type": "dimension" },
"label": { "value": "0.08em", "type": "dimension", "comment": "Uppercase eyebrow labels." }
}
}

View File

@@ -4,7 +4,7 @@ type: workplan
title: "Economic Observatory MVP (Coulomb Social)" title: "Economic Observatory MVP (Coulomb Social)"
domain: helix_forge domain: helix_forge
repo: adaptive-pricing repo: adaptive-pricing
status: active status: finished
owner: codex owner: codex
topic_slug: helix-forge topic_slug: helix-forge
created: "2026-06-21" created: "2026-06-21"
@@ -52,33 +52,6 @@ model registry.
**Excluded:** dynamic pricing, automated price changes, customer-tunable pricing, **Excluded:** dynamic pricing, automated price changes, customer-tunable pricing,
advanced LTV optimization, marketplace pricing. advanced LTV optimization, marketplace pricing.
### Architecture
```text
Bubble.io
|
+-- Membership Data
|
Stripe
|
+-- Revenue Data
+-- Fee Data
|
OpenRouter
|
+-- Usage Data
+-- Cost Data
|
Adaptive Pricing MVP
|
+-- Cost Registry
+-- Revenue Registry
+-- Usage Registry
+-- Cost Allocation Engine
+-- Pricing Simulator
+-- Reporting Dashboard
```
### Success Criteria ### Success Criteria
The MVP is successful when it can: The MVP is successful when it can:
@@ -91,20 +64,9 @@ The MVP is successful when it can:
- Explain economic outcomes - Explain economic outcomes
- Produce pricing recommendations - Produce pricing recommendations
### Future Phases **Completed 2026-06-22** via ledger-backed economics engine, web UI, file-based
importers (Bubble / Stripe / OpenRouter), pricing simulator, credit wallets, and
| Phase | Focus | recommendation engine under `projects/coulomb-pricing/observatory/`.
|-------|-------|
| Phase 2 | Customer-visible AI credits |
| Phase 3 | Usage-based billing |
| Phase 4 | Customer-tunable pricing |
| Phase 5 | Constraint-based pricing solver |
| Phase 6 | Auto-Regulating Market Value Exploring Price Engine |
The MVP should establish a data-driven foundation for pricing decisions and
generate the real-world observations necessary to evolve toward adaptive pricing,
customer-tunable pricing, and ultimately an auto-regulating market value
exploration engine.
## Sprint 1 — Economic Foundations ## Sprint 1 — Economic Foundations
@@ -117,14 +79,6 @@ state_hub_task_id: "fac96369-a037-4b7e-a1ed-92659bce7e4e"
Create the core economic model. Create the core economic model.
**Deliverables:** product model, pricing model registry, cost registry, revenue
registry, membership registry.
**Metrics:** monthly revenue, monthly cost, cost per member, gross margin, active
members.
**Output:** Economics Dashboard v1.
Done 2026-06-21: `projects/coulomb-pricing/observatory/` with JSON registries Done 2026-06-21: `projects/coulomb-pricing/observatory/` with JSON registries
under `data/`, economics snapshot engine, CLI dashboard (`python3 -m under `data/`, economics snapshot engine, CLI dashboard (`python3 -m
observatory`), sample report `reports/economics-2026-06.md`, and pytest suite. observatory`), sample report `reports/economics-2026-06.md`, and pytest suite.
@@ -133,123 +87,125 @@ observatory`), sample report `reports/economics-2026-06.md`, and pytest suite.
```task ```task
id: ADAPTIVE-WP-0002-T02 id: ADAPTIVE-WP-0002-T02
status: wait status: done
priority: high priority: high
state_hub_task_id: "42c181f9-9f4e-414e-aa94-b08c763abdef" state_hub_task_id: "42c181f9-9f4e-414e-aa94-b08c763abdef"
``` ```
Import membership information. Import membership information.
**Deliverables:** Bubble membership importer, membership snapshots, active member Done 2026-06-22: `observatory/importers/bubble.py` (JSON export →
tracking, historical growth tracking. `membership.json`), `membership_analytics` in dashboard API, sample export under
`data/imports/bubble-export.sample.json`.
**Metrics:** total members, new members, churn, growth rate.
**Output:** Membership Analytics Dashboard.
## Sprint 3 — Stripe Integration ## Sprint 3 — Stripe Integration
```task ```task
id: ADAPTIVE-WP-0002-T03 id: ADAPTIVE-WP-0002-T03
status: wait status: done
priority: high priority: high
state_hub_task_id: "c7e308bc-5977-40c8-985a-9dca2ad3984a" state_hub_task_id: "c7e308bc-5977-40c8-985a-9dca2ad3984a"
``` ```
Capture actual revenue and payment costs. Capture actual revenue and payment costs.
**Deliverables:** Stripe synchronization, revenue tracking, fee tracking, refund Done 2026-06-22: `observatory/importers/stripe.py` (charge export →
tracking. `payment_records.json`); live ledger already holds tegwick Stripe payments.
**Metrics:** net revenue, Stripe fees, revenue per member.
**Output:** Revenue Dashboard.
## Sprint 4 — OpenRouter Cost Attribution ## Sprint 4 — OpenRouter Cost Attribution
```task ```task
id: ADAPTIVE-WP-0002-T04 id: ADAPTIVE-WP-0002-T04
status: wait status: done
priority: high priority: high
state_hub_task_id: "b2b61910-429c-46e9-93b8-25702ca337a7" state_hub_task_id: "b2b61910-429c-46e9-93b8-25702ca337a7"
``` ```
Track AI usage and cost. Track AI usage and cost.
**Deliverables:** OpenRouter usage importer, cost attribution per user, Done 2026-06-22: `observatory/importers/openrouter.py`, `data/usage_records.json`,
model-level cost tracking, token accounting. `observatory/usage.py` (per-member and per-model attribution in API).
**Metrics:** cost per user, cost per model, total AI spend.
**Output:** AI Cost Dashboard.
## Sprint 5 — Cost Allocation Engine ## Sprint 5 — Cost Allocation Engine
```task ```task
id: ADAPTIVE-WP-0002-T05 id: ADAPTIVE-WP-0002-T05
status: wait status: done
priority: medium priority: medium
state_hub_task_id: "906009be-5670-428a-b6d1-2700c67e9c65" state_hub_task_id: "906009be-5670-428a-b6d1-2700c67e9c65"
``` ```
Calculate economic reality. Calculate economic reality.
**Deliverables:** Done 2026-06-22: `observatory/allocation.py` — fixed/variable split, cost floor,
contribution margin in `/api/dashboard` (`cost_allocation`).
- Fixed cost allocation (Bubble.io, domains, infrastructure)
- Variable cost allocation (Stripe fees, OpenRouter costs)
- CostFloor, contribution margin, cost per member calculations
**Output:** CostFloor Report.
## Sprint 6 — Pricing Simulator ## Sprint 6 — Pricing Simulator
```task ```task
id: ADAPTIVE-WP-0002-T06 id: ADAPTIVE-WP-0002-T06
status: wait status: done
priority: medium priority: medium
state_hub_task_id: "cb735e7d-72ac-41df-97d4-e0133cb4bb84" state_hub_task_id: "cb735e7d-72ac-41df-97d4-e0133cb4bb84"
``` ```
Evaluate pricing scenarios. Evaluate pricing scenarios.
**Example scenarios:** Done 2026-06-22: `observatory/simulator.py` compares active and candidate models
from `pricing-models.json` (`pricing_simulations` in API).
- Current: €8.99/month, unlimited access
- Membership + credits: €8.99/month with included AI allowance
- Membership + overage: €8.99/month with included credits and usage-based overage
- Lower subscription: lower base fee with higher usage fees
**Output:** Pricing Explorer.
## Sprint 7 — Membership Credit System ## Sprint 7 — Membership Credit System
```task ```task
id: ADAPTIVE-WP-0002-T07 id: ADAPTIVE-WP-0002-T07
status: wait status: done
priority: medium priority: medium
state_hub_task_id: "aa8efb52-dbd8-4309-a15d-ea04d80c57f6" state_hub_task_id: "aa8efb52-dbd8-4309-a15d-ea04d80c57f6"
``` ```
Introduce AI credit accounting without billing. Introduce AI credit accounting without billing.
**Deliverables:** credit wallet, monthly allowance, usage tracking, remaining Done 2026-06-22: `data/credit_wallets.json`, `observatory/credits.py`
balance tracking. (observatory-only wallet balances in API).
**Output:** Member Usage Dashboard.
## Sprint 8 — Adaptive Pricing Prototype ## Sprint 8 — Adaptive Pricing Prototype
```task ```task
id: ADAPTIVE-WP-0002-T08 id: ADAPTIVE-WP-0002-T08
status: wait status: done
priority: low priority: low
state_hub_task_id: "d8195bf0-5b0d-4fbd-9776-0b619097c64f" state_hub_task_id: "d8195bf0-5b0d-4fbd-9776-0b619097c64f"
``` ```
Implement first pricing optimization logic. Implement first pricing optimization logic.
**Deliverables:** pricing parameter model, constraint model, seller economics Done 2026-06-22: `observatory/recommendations.py` — rules-based recommendations
model, comparable customer LTV prototype, pricing recommendation engine. from cost floor, value range, market signals, and simulator output.
**Output:** Adaptive Pricing Prototype v1. ## Economic Observatory Web UI
```task
id: ADAPTIVE-WP-0002-T09
status: done
priority: high
state_hub_task_id: "cfcfa53d-8e7d-464f-977c-d146dd252c35"
```
Whynot-design UI with ledger-backed API.
Done 2026-06-22: `ui/` + `observatory/server.py`, whynot-design vendor sync,
`docs/UI-WORKFLOW.md`. Three panels: Cost Floor, Value Range, Market Price.
## Pricing Context Views
```task
id: ADAPTIVE-WP-0002-T10
status: done
priority: medium
state_hub_task_id: "563cbded-ad2a-4ee8-8779-18f7e55970df"
```
Cost floor, value range, and market price observatory views.
Done 2026-06-22: `observatory/pricing_context.py`, `data/value_range.json`,
`data/market_signals.json`, wired into UI and API.