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 — adaptive-pricing
|
||||
|
||||
**Domain:** helix_forge
|
||||
**Last synced:** 2026-06-21 23:32 UTC
|
||||
**Domain:** infotech
|
||||
**Last synced:** 2026-06-22 21:23 UTC
|
||||
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
||||
|
||||
## Active Workstreams
|
||||
|
||||
### Economic Observatory MVP (Coulomb Social)
|
||||
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`
|
||||
*(none — repo may need first-session setup)*
|
||||
|
||||
---
|
||||
## MCP Orientation (when available)
|
||||
|
||||
If the state-hub MCP server is reachable, call:
|
||||
`get_domain_summary("helix_forge")`
|
||||
`get_domain_summary("infotech")`
|
||||
This provides richer cross-domain context.
|
||||
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.
|
||||
|
||||
**Domain:** helix_forge
|
||||
**Domain:** financials
|
||||
**Repo slug:** adaptive-pricing
|
||||
**Topic ID:** `f39fa2a3-c491-414c-a91b-b4c5fcc6139c`
|
||||
**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
|
||||
|
||||
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`
|
||||
|
||||
---
|
||||
|
||||
<!-- REPO-AGENTS-EXTENSIONS -->
|
||||
<!-- Append repo-specific agent instructions below this marker.
|
||||
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
|
||||
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
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
This directory holds implementation artifacts, integrations, and documentation that
|
||||
apply to the Coulomb deployment only. Generic adaptive-pricing framework concepts
|
||||
belong in the repository root (`INTENT.md`, `docs/`, `research/`, `registry/`).
|
||||
Generic adaptive-pricing framework concepts belong in the repository root
|
||||
(`INTENT.md`, `docs/`, `research/`, `registry/`). Execution tracking:
|
||||
`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
|
||||
Coulomb offerings and related product ecosystems.
|
||||
## Economic Observatory
|
||||
|
||||
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)"
|
||||
domain: helix_forge
|
||||
repo: adaptive-pricing
|
||||
status: active
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: helix-forge
|
||||
created: "2026-06-21"
|
||||
@@ -52,33 +52,6 @@ model registry.
|
||||
**Excluded:** dynamic pricing, automated price changes, customer-tunable 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
|
||||
|
||||
The MVP is successful when it can:
|
||||
@@ -91,20 +64,9 @@ The MVP is successful when it can:
|
||||
- Explain economic outcomes
|
||||
- Produce pricing recommendations
|
||||
|
||||
### Future Phases
|
||||
|
||||
| Phase | Focus |
|
||||
|-------|-------|
|
||||
| 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.
|
||||
**Completed 2026-06-22** via ledger-backed economics engine, web UI, file-based
|
||||
importers (Bubble / Stripe / OpenRouter), pricing simulator, credit wallets, and
|
||||
recommendation engine under `projects/coulomb-pricing/observatory/`.
|
||||
|
||||
## Sprint 1 — Economic Foundations
|
||||
|
||||
@@ -117,14 +79,6 @@ state_hub_task_id: "fac96369-a037-4b7e-a1ed-92659bce7e4e"
|
||||
|
||||
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
|
||||
under `data/`, economics snapshot engine, CLI dashboard (`python3 -m
|
||||
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
|
||||
id: ADAPTIVE-WP-0002-T02
|
||||
status: wait
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "42c181f9-9f4e-414e-aa94-b08c763abdef"
|
||||
```
|
||||
|
||||
Import membership information.
|
||||
|
||||
**Deliverables:** Bubble membership importer, membership snapshots, active member
|
||||
tracking, historical growth tracking.
|
||||
|
||||
**Metrics:** total members, new members, churn, growth rate.
|
||||
|
||||
**Output:** Membership Analytics Dashboard.
|
||||
Done 2026-06-22: `observatory/importers/bubble.py` (JSON export →
|
||||
`membership.json`), `membership_analytics` in dashboard API, sample export under
|
||||
`data/imports/bubble-export.sample.json`.
|
||||
|
||||
## Sprint 3 — Stripe Integration
|
||||
|
||||
```task
|
||||
id: ADAPTIVE-WP-0002-T03
|
||||
status: wait
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "c7e308bc-5977-40c8-985a-9dca2ad3984a"
|
||||
```
|
||||
|
||||
Capture actual revenue and payment costs.
|
||||
|
||||
**Deliverables:** Stripe synchronization, revenue tracking, fee tracking, refund
|
||||
tracking.
|
||||
|
||||
**Metrics:** net revenue, Stripe fees, revenue per member.
|
||||
|
||||
**Output:** Revenue Dashboard.
|
||||
Done 2026-06-22: `observatory/importers/stripe.py` (charge export →
|
||||
`payment_records.json`); live ledger already holds tegwick Stripe payments.
|
||||
|
||||
## Sprint 4 — OpenRouter Cost Attribution
|
||||
|
||||
```task
|
||||
id: ADAPTIVE-WP-0002-T04
|
||||
status: wait
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "b2b61910-429c-46e9-93b8-25702ca337a7"
|
||||
```
|
||||
|
||||
Track AI usage and cost.
|
||||
|
||||
**Deliverables:** OpenRouter usage importer, cost attribution per user,
|
||||
model-level cost tracking, token accounting.
|
||||
|
||||
**Metrics:** cost per user, cost per model, total AI spend.
|
||||
|
||||
**Output:** AI Cost Dashboard.
|
||||
Done 2026-06-22: `observatory/importers/openrouter.py`, `data/usage_records.json`,
|
||||
`observatory/usage.py` (per-member and per-model attribution in API).
|
||||
|
||||
## Sprint 5 — Cost Allocation Engine
|
||||
|
||||
```task
|
||||
id: ADAPTIVE-WP-0002-T05
|
||||
status: wait
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "906009be-5670-428a-b6d1-2700c67e9c65"
|
||||
```
|
||||
|
||||
Calculate economic reality.
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
- Fixed cost allocation (Bubble.io, domains, infrastructure)
|
||||
- Variable cost allocation (Stripe fees, OpenRouter costs)
|
||||
- CostFloor, contribution margin, cost per member calculations
|
||||
|
||||
**Output:** CostFloor Report.
|
||||
Done 2026-06-22: `observatory/allocation.py` — fixed/variable split, cost floor,
|
||||
contribution margin in `/api/dashboard` (`cost_allocation`).
|
||||
|
||||
## Sprint 6 — Pricing Simulator
|
||||
|
||||
```task
|
||||
id: ADAPTIVE-WP-0002-T06
|
||||
status: wait
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "cb735e7d-72ac-41df-97d4-e0133cb4bb84"
|
||||
```
|
||||
|
||||
Evaluate pricing scenarios.
|
||||
|
||||
**Example scenarios:**
|
||||
|
||||
- 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.
|
||||
Done 2026-06-22: `observatory/simulator.py` compares active and candidate models
|
||||
from `pricing-models.json` (`pricing_simulations` in API).
|
||||
|
||||
## Sprint 7 — Membership Credit System
|
||||
|
||||
```task
|
||||
id: ADAPTIVE-WP-0002-T07
|
||||
status: wait
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "aa8efb52-dbd8-4309-a15d-ea04d80c57f6"
|
||||
```
|
||||
|
||||
Introduce AI credit accounting without billing.
|
||||
|
||||
**Deliverables:** credit wallet, monthly allowance, usage tracking, remaining
|
||||
balance tracking.
|
||||
|
||||
**Output:** Member Usage Dashboard.
|
||||
Done 2026-06-22: `data/credit_wallets.json`, `observatory/credits.py`
|
||||
(observatory-only wallet balances in API).
|
||||
|
||||
## Sprint 8 — Adaptive Pricing Prototype
|
||||
|
||||
```task
|
||||
id: ADAPTIVE-WP-0002-T08
|
||||
status: wait
|
||||
status: done
|
||||
priority: low
|
||||
state_hub_task_id: "d8195bf0-5b0d-4fbd-9776-0b619097c64f"
|
||||
```
|
||||
|
||||
Implement first pricing optimization logic.
|
||||
|
||||
**Deliverables:** pricing parameter model, constraint model, seller economics
|
||||
model, comparable customer LTV prototype, pricing recommendation engine.
|
||||
Done 2026-06-22: `observatory/recommendations.py` — rules-based recommendations
|
||||
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