generated from coulomb/repo-seed
Compare commits
18 Commits
6c02a0cfa9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a38def5a5 | |||
| 04ee6d2421 | |||
| 4ef04dd5e5 | |||
| 1bdb518a94 | |||
| f8bd6f912f | |||
| a1a90a9504 | |||
| da3b7d66f0 | |||
| 9c1c2142fc | |||
| 7b84d34ea6 | |||
| bb3f152846 | |||
| fc2324692c | |||
| 86ce511764 | |||
| 31db9f8f31 | |||
| ea2c2c6403 | |||
| fe2174f37a | |||
| 8f42220d81 | |||
| a1a4aa972f | |||
| d648a3263d |
20
.claude/rules/agents.md
Normal file
20
.claude/rules/agents.md
Normal 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.
|
||||||
8
.claude/rules/architecture.md
Normal file
8
.claude/rules/architecture.md
Normal 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
|
||||||
38
.claude/rules/first-session.md
Normal file
38
.claude/rules/first-session.md
Normal 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 1–3 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 -->
|
||||||
8
.claude/rules/repo-boundary.md
Normal file
8
.claude/rules/repo-boundary.md
Normal 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/
|
||||||
|
-->
|
||||||
5
.claude/rules/repo-identity.md
Normal file
5
.claude/rules/repo-identity.md
Normal 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
|
||||||
85
.claude/rules/session-protocol.md
Normal file
85
.claude/rules/session-protocol.md
Normal 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.
|
||||||
19
.claude/rules/stack-and-commands.md
Normal file
19
.claude/rules/stack-and-commands.md
Normal 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)
|
||||||
|
```
|
||||||
40
.claude/rules/workplan-convention.md
Normal file
40
.claude/rules/workplan-convention.md
Normal 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 -->
|
||||||
@@ -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
27
.repo-classification.yaml
Normal 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.
|
||||||
30
AGENTS.md
30
AGENTS.md
@@ -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
12
CLAUDE.md
Normal 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
|
||||||
@@ -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.
|
||||||
19
projects/coulomb-pricing/Makefile
Normal file
19
projects/coulomb-pricing/Makefile
Normal 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
|
||||||
@@ -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.
|
||||||
83
projects/coulomb-pricing/REQUIREMENTS.md
Normal file
83
projects/coulomb-pricing/REQUIREMENTS.md
Normal 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.
|
||||||
7
projects/coulomb-pricing/data/budget.json
Normal file
7
projects/coulomb-pricing/data/budget.json
Normal 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."
|
||||||
|
}
|
||||||
11
projects/coulomb-pricing/data/credit_wallets.json
Normal file
11
projects/coulomb-pricing/data/credit_wallets.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
586
projects/coulomb-pricing/data/expense_records.json
Normal file
586
projects/coulomb-pricing/data/expense_records.json
Normal 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."
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"usage": [
|
||||||
|
{
|
||||||
|
"period": "2026-06",
|
||||||
|
"user_id": "member-tegwick",
|
||||||
|
"model": "anthropic/claude-3-haiku",
|
||||||
|
"tokens": 48200,
|
||||||
|
"cost_usd": "0.06"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
26
projects/coulomb-pricing/data/infrastructure/domains.json
Normal file
26
projects/coulomb-pricing/data/infrastructure/domains.json
Normal 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."
|
||||||
|
}
|
||||||
13
projects/coulomb-pricing/data/infrastructure/stripe.json
Normal file
13
projects/coulomb-pricing/data/infrastructure/stripe.json
Normal 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."
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
56
projects/coulomb-pricing/data/market_signals.json
Normal file
56
projects/coulomb-pricing/data/market_signals.json
Normal 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."
|
||||||
|
}
|
||||||
17
projects/coulomb-pricing/data/membership.json
Normal file
17
projects/coulomb-pricing/data/membership.json
Normal 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."
|
||||||
|
}
|
||||||
118
projects/coulomb-pricing/data/payment_records.json
Normal file
118
projects/coulomb-pricing/data/payment_records.json
Normal 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)."
|
||||||
|
}
|
||||||
39
projects/coulomb-pricing/data/pricing-models.json
Normal file
39
projects/coulomb-pricing/data/pricing-models.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
12
projects/coulomb-pricing/data/product.json
Normal file
12
projects/coulomb-pricing/data/product.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
16
projects/coulomb-pricing/data/usage_records.json
Normal file
16
projects/coulomb-pricing/data/usage_records.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
40
projects/coulomb-pricing/data/value_range.json
Normal file
40
projects/coulomb-pricing/data/value_range.json
Normal 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."
|
||||||
|
}
|
||||||
58
projects/coulomb-pricing/docs/UI-WORKFLOW.md
Normal file
58
projects/coulomb-pricing/docs/UI-WORKFLOW.md
Normal 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`.
|
||||||
3
projects/coulomb-pricing/observatory/__init__.py
Normal file
3
projects/coulomb-pricing/observatory/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""Coulomb Social Economic Observatory — MVP (ledger, API, importers, simulator)."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
3
projects/coulomb-pricing/observatory/__main__.py
Normal file
3
projects/coulomb-pricing/observatory/__main__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .dashboard import main
|
||||||
|
|
||||||
|
raise SystemExit(main())
|
||||||
32
projects/coulomb-pricing/observatory/allocation.py
Normal file
32
projects/coulomb-pricing/observatory/allocation.py
Normal 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,
|
||||||
|
}
|
||||||
138
projects/coulomb-pricing/observatory/api.py
Normal file
138
projects/coulomb-pricing/observatory/api.py
Normal 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)
|
||||||
48
projects/coulomb-pricing/observatory/credits.py
Normal file
48
projects/coulomb-pricing/observatory/credits.py
Normal 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.",
|
||||||
|
}
|
||||||
170
projects/coulomb-pricing/observatory/dashboard.py
Normal file
170
projects/coulomb-pricing/observatory/dashboard.py
Normal 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())
|
||||||
155
projects/coulomb-pricing/observatory/economics.py
Normal file
155
projects/coulomb-pricing/observatory/economics.py
Normal 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),
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""File-based importers for Bubble, Stripe, and OpenRouter exports."""
|
||||||
13
projects/coulomb-pricing/observatory/importers/_io.py
Normal file
13
projects/coulomb-pricing/observatory/importers/_io.py
Normal 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")
|
||||||
55
projects/coulomb-pricing/observatory/importers/bubble.py
Normal file
55
projects/coulomb-pricing/observatory/importers/bubble.py
Normal 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())
|
||||||
57
projects/coulomb-pricing/observatory/importers/openrouter.py
Normal file
57
projects/coulomb-pricing/observatory/importers/openrouter.py
Normal 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())
|
||||||
54
projects/coulomb-pricing/observatory/importers/stripe.py
Normal file
54
projects/coulomb-pricing/observatory/importers/stripe.py
Normal 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())
|
||||||
121
projects/coulomb-pricing/observatory/ledger.py
Normal file
121
projects/coulomb-pricing/observatory/ledger.py
Normal 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
|
||||||
153
projects/coulomb-pricing/observatory/load.py
Normal file
153
projects/coulomb-pricing/observatory/load.py
Normal 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)
|
||||||
48
projects/coulomb-pricing/observatory/membership_analytics.py
Normal file
48
projects/coulomb-pricing/observatory/membership_analytics.py
Normal 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",
|
||||||
|
}
|
||||||
121
projects/coulomb-pricing/observatory/models.py
Normal file
121
projects/coulomb-pricing/observatory/models.py
Normal 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
|
||||||
93
projects/coulomb-pricing/observatory/pricing_context.py
Normal file
93
projects/coulomb-pricing/observatory/pricing_context.py
Normal 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", ""),
|
||||||
|
}
|
||||||
68
projects/coulomb-pricing/observatory/recommendations.py
Normal file
68
projects/coulomb-pricing/observatory/recommendations.py
Normal 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
|
||||||
81
projects/coulomb-pricing/observatory/server.py
Normal file
81
projects/coulomb-pricing/observatory/server.py
Normal 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())
|
||||||
70
projects/coulomb-pricing/observatory/simulator.py
Normal file
70
projects/coulomb-pricing/observatory/simulator.py
Normal 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.",
|
||||||
|
}
|
||||||
48
projects/coulomb-pricing/observatory/usage.py
Normal file
48
projects/coulomb-pricing/observatory/usage.py
Normal 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),
|
||||||
|
}
|
||||||
81
projects/coulomb-pricing/reports/economics-2026-06.md
Normal file
81
projects/coulomb-pricing/reports/economics-2026-06.md
Normal 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`)
|
||||||
47
projects/coulomb-pricing/scripts/sync-whynot-design.sh
Executable file
47
projects/coulomb-pricing/scripts/sync-whynot-design.sh
Executable 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"))"
|
||||||
6
projects/coulomb-pricing/tests/conftest.py
Normal file
6
projects/coulomb-pricing/tests/conftest.py
Normal 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))
|
||||||
32
projects/coulomb-pricing/tests/test_api.py
Normal file
32
projects/coulomb-pricing/tests/test_api.py
Normal 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")
|
||||||
117
projects/coulomb-pricing/tests/test_economics.py
Normal file
117
projects/coulomb-pricing/tests/test_economics.py
Normal 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
|
||||||
42
projects/coulomb-pricing/tests/test_importers.py
Normal file
42
projects/coulomb-pricing/tests/test_importers.py
Normal 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"
|
||||||
96
projects/coulomb-pricing/tests/test_mvp_sprints.py
Normal file
96
projects/coulomb-pricing/tests/test_mvp_sprints.py
Normal 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
|
||||||
61
projects/coulomb-pricing/tests/test_pricing_context.py
Normal file
61
projects/coulomb-pricing/tests/test_pricing_context.py
Normal 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")
|
||||||
62
projects/coulomb-pricing/tests/test_ui_vendor.py
Normal file
62
projects/coulomb-pricing/tests/test_ui_vendor.py
Normal 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)
|
||||||
416
projects/coulomb-pricing/ui/app.js
Normal file
416
projects/coulomb-pricing/ui/app.js
Normal 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>`;
|
||||||
|
});
|
||||||
208
projects/coulomb-pricing/ui/index.html
Normal file
208
projects/coulomb-pricing/ui/index.html
Normal 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 & 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>
|
||||||
256
projects/coulomb-pricing/ui/styles.css
Normal file
256
projects/coulomb-pricing/ui/styles.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
projects/coulomb-pricing/ui/vendor/whynot-design/.whynot-design-ref
vendored
Normal file
1
projects/coulomb-pricing/ui/vendor/whynot-design/.whynot-design-ref
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
9b9f3728937ca308966de9c62accdb00c8cf5b0e
|
||||||
273
projects/coulomb-pricing/ui/vendor/whynot-design/colors_and_type.css
vendored
Normal file
273
projects/coulomb-pricing/ui/vendor/whynot-design/colors_and_type.css
vendored
Normal 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); }
|
||||||
590
projects/coulomb-pricing/ui/vendor/whynot-design/components.css
vendored
Normal file
590
projects/coulomb-pricing/ui/vendor/whynot-design/components.css
vendored
Normal 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 (S0–S4) */
|
||||||
|
.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; }
|
||||||
604
projects/coulomb-pricing/ui/vendor/whynot-design/elements/_styles.js
vendored
Normal file
604
projects/coulomb-pricing/ui/vendor/whynot-design/elements/_styles.js
vendored
Normal 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 (S0–S4) */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
164
projects/coulomb-pricing/ui/vendor/whynot-design/elements/atoms.js
vendored
Normal file
164
projects/coulomb-pricing/ui/vendor/whynot-design/elements/atoms.js
vendored
Normal 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 };
|
||||||
206
projects/coulomb-pricing/ui/vendor/whynot-design/elements/chrome.js
vendored
Normal file
206
projects/coulomb-pricing/ui/vendor/whynot-design/elements/chrome.js
vendored
Normal 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);
|
||||||
|
}
|
||||||
205
projects/coulomb-pricing/ui/vendor/whynot-design/elements/form.js
vendored
Normal file
205
projects/coulomb-pricing/ui/vendor/whynot-design/elements/form.js
vendored
Normal 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);
|
||||||
|
}
|
||||||
45
projects/coulomb-pricing/ui/vendor/whynot-design/elements/icons.js
vendored
Normal file
45
projects/coulomb-pricing/ui/vendor/whynot-design/elements/icons.js
vendored
Normal 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"],
|
||||||
|
};
|
||||||
277
projects/coulomb-pricing/ui/vendor/whynot-design/elements/layout.js
vendored
Normal file
277
projects/coulomb-pricing/ui/vendor/whynot-design/elements/layout.js
vendored
Normal 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);
|
||||||
|
}
|
||||||
35
projects/coulomb-pricing/ui/vendor/whynot-design/index.js
vendored
Normal file
35
projects/coulomb-pricing/ui/vendor/whynot-design/index.js
vendored
Normal 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";
|
||||||
22
projects/coulomb-pricing/ui/vendor/whynot-design/tokens/colors.json
vendored
Normal file
22
projects/coulomb-pricing/ui/vendor/whynot-design/tokens/colors.json
vendored
Normal 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" }
|
||||||
|
}
|
||||||
6
projects/coulomb-pricing/ui/vendor/whynot-design/tokens/index.json
vendored
Normal file
6
projects/coulomb-pricing/ui/vendor/whynot-design/tokens/index.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
28
projects/coulomb-pricing/ui/vendor/whynot-design/tokens/spacing.json
vendored
Normal file
28
projects/coulomb-pricing/ui/vendor/whynot-design/tokens/spacing.json
vendored
Normal 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." }
|
||||||
|
}
|
||||||
|
}
|
||||||
33
projects/coulomb-pricing/ui/vendor/whynot-design/tokens/type.json
vendored
Normal file
33
projects/coulomb-pricing/ui/vendor/whynot-design/tokens/type.json
vendored
Normal 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." }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user