Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15f6e39164 | ||
|
|
175049768c | ||
|
|
8edea32279 | ||
|
|
943e084482 | ||
|
|
38a7006bea | ||
|
|
9eb0c833a6 | ||
|
|
3ac25db0ce | ||
|
|
d16c6acab6 | ||
|
|
8c27c537c1 | ||
|
|
5e2d211609 | ||
|
|
fb1df02f64 | ||
|
|
0e582aa70b | ||
|
|
d786ec6500 | ||
|
|
498285e136 | ||
|
|
e705ca00ac | ||
|
|
aaeb9417ca | ||
|
|
721212ba7e | ||
|
|
08bba21dfa | ||
|
|
7d76ab4338 | ||
|
|
b02e73c0e6 | ||
|
|
d30be0208d | ||
|
|
1da01fcd68 | ||
|
|
0caaad4a1a | ||
|
|
495494b0e7 | ||
|
|
56b74116e7 | ||
|
|
97b7667970 | ||
|
|
195c65cb55 | ||
|
|
f19429bccb | ||
|
|
7e9ae24beb | ||
|
|
c2055e85eb | ||
|
|
b13ddff34f | ||
|
|
f718a14295 | ||
|
|
e46ec071c1 | ||
|
|
10d0466ad9 | ||
|
|
f3a841a762 | ||
|
|
bbc62970cb | ||
|
|
8a14773aa0 | ||
|
|
223a4a0617 | ||
|
|
e12728c5ff | ||
|
|
a694c10428 | ||
|
|
0eb9234a7e | ||
|
|
444b01abca | ||
|
|
4086657dc8 | ||
|
|
aecc4ee90d | ||
|
|
c92313b62d | ||
|
|
dc2a3e8a05 | ||
|
|
a871722024 | ||
|
|
6bf0b0411b | ||
|
|
7c2b6a00a3 | ||
|
|
d05679b11a | ||
|
|
ee0302f763 | ||
|
|
387fe44e15 | ||
|
|
9ffbb793bf | ||
|
|
bd6fcd7ed7 | ||
|
|
8c874e8115 | ||
|
|
c360639456 | ||
|
|
8c6ad054b9 | ||
|
|
17e51a6b08 | ||
|
|
e52e116815 | ||
|
|
2d0f997030 | ||
|
|
3086cb52ff | ||
|
|
9e065e416d | ||
|
|
165d499644 | ||
|
|
8eff78170b | ||
|
|
ba4174326a | ||
|
|
88f9d509ed | ||
|
|
c6451370a7 | ||
|
|
a933c17fdb | ||
|
|
fbe1b0c9a2 | ||
|
|
bc6f232a56 | ||
|
|
c54cbf2596 | ||
|
|
cb687873d3 | ||
|
|
134a03ce41 | ||
|
|
e1dc4f2790 | ||
|
|
32fa6ee49d | ||
|
|
7a821c5cf2 | ||
|
|
6d0c600de0 | ||
|
|
e8843b7e62 | ||
|
|
51811c2ba0 | ||
|
|
99511c6c08 | ||
|
|
398af5a9ac | ||
|
|
c97198ea15 | ||
|
|
23d6383060 | ||
|
|
f64a48300d | ||
|
|
35860f4bf8 | ||
|
|
3bf9fbe94d | ||
|
|
67f7b4d2d9 | ||
|
|
16cd9a6306 | ||
|
|
c287a2acf6 | ||
|
|
4fc82f0f9a | ||
|
|
0f74a7d7c4 | ||
|
|
0c569ca5c2 | ||
|
|
79e38fa894 | ||
|
|
91b11019db | ||
|
|
cdfe4c7199 | ||
|
|
66c427eba4 | ||
|
|
5a185facd3 | ||
|
|
a4970e3e2b | ||
|
|
bae0bdc0ef | ||
|
|
fd23310271 |
No files matched your search
@@ -38,7 +38,7 @@ Files: `.github/workflows/{heartbeat,mention,doctor,update}.yml`, `.github/time/
|
||||
- `gh variable set TIME_MODE --body dry-run`
|
||||
- `gh variable set TIME_DAILY_CAP --body 5` — max agent launches per day
|
||||
- `TIME_WORKER`: `cursor` (default) or `venice:<seat> [model] [effort]`, e.g. `venice:kimi code`. A Venice seat needs only `VENICE_API_KEY`: it reads and writes the repo on branch `time/card-N` through the GitHub API, runs tests on the runner, and opens the PR itself. Workflow files are refused to it.
|
||||
- optional `TIME_CURSOR_MODEL` (Cursor worker model id), `TIME_REVIEW`, `TIME_STUCK_HOURS` (default 6), `TIME_WORKER_STEPS` (default 40 tool calls per card)
|
||||
- optional `TIME_CURSOR_MODEL` (Cursor worker model id), `TIME_REVIEW`, `TIME_STUCK_HOURS` (default 6), `TIME_WORKER_STEPS` (default 40 tool calls per card), `TIME_WORKER_FALLBACK` (default `grok build`: takes over when the worker's model stays overloaded; `off` to disable), `TIME_WORKER_MAX_TOKENS` (default 16000)
|
||||
4. Cursor (the worker, only when `TIME_WORKER` is `cursor`):
|
||||
- The human installs Cursor's GitHub app on this repo (Cursor dashboard → Integrations → GitHub). Cloud agents can't clone or open PRs without it. You can't check this from `gh`; ask them to confirm.
|
||||
- The human creates an API key (Cursor dashboard → API Keys) and stores it: `gh secret set CURSOR_API_KEY` (they paste the value at the prompt).
|
||||
@@ -110,3 +110,20 @@ Only when the human says so: `gh variable set TIME_MODE --body live`. The daily
|
||||
- Runner minutes: private repos have a monthly free allowance, each job rounds up to a minute; keep the heartbeat hourly unless asked. Cursor agent runs bill to the human's Cursor plan.
|
||||
- Changed `time.py`? Run `python3 .github/time/test_time.py` before committing.
|
||||
- Status: `gh issue list -l ready`, `gh run list -L 10`, and the latest Time comment on each card.
|
||||
|
||||
## Ventures (optional)
|
||||
|
||||
`gh variable set TIME_VENTURE --body on` starts the venture loop (`venture.yml`, every 20 minutes): one venture card a day, from the repos trending on GitHub to a launch page on GitHub Pages. Seats pitch, the board votes, board seats write launch plans that one seat synthesizes into `LAUNCH.md`, the worker builds the product as the page on `gh-pages` (the tool fills the first screen), deterministic checks and headless Chrome gate it, and seven days of signups decide (v2, kill, or one more week). It needs `VENICE_API_KEY`; the loop creates the `gh-pages` branch, the Pages site and the `signup` label if it can.
|
||||
- Start one now, whatever the day: `gh workflow run venture.yml -f start=true` (or Actions → venture → Run workflow → "Start a new venture now"). A second card on the same day is titled `Venture <day> (2)`.
|
||||
- Signups come in through the issue form `.github/ISSUE_TEMPLATE/early-access.yml` (label `signup`); the loop thanks each one on its issue. A signup is a distinct GitHub account (not the owner, not a bot) that opened an early-access issue or 👍'd the venture's signal issue.
|
||||
- Settings: `TIME_VENTURE_PITCH`, `TIME_VENTURE_BOARD` (comma-separated seats), `TIME_VENTURE_CHAIR` (synthesizes the launch plans), `TIME_VENTURE_MEASURE_DAYS` (default 7).
|
||||
- A page that still fails its checks after two fix rounds gets `needs-human`: fix it on `gh-pages` and comment `/time retry` on the card. A Venice outage only makes a stage wait; it never closes a card.
|
||||
- The studio index is generated at `/ventures/` from `ventures/ventures.json`; launch posts are drafts on the card, never sent.
|
||||
|
||||
### Backends on Cloudflare (optional)
|
||||
|
||||
With a Cloudflare API token, each venture also gets a backend: its own Worker (`time-<slug>`, code in `.github/time/cloudflare/worker.js`), its own D1 database and Workers AI, serving the venture's Pages folder under its own domain. The page reaches it through `time.js` (written next to `index.html`; the build never edits it): `Time.save` / `Time.load` for share links, `Time.ai` for a small model, and accounts with passkeys (`Time.signUp`, `Time.signIn`, `Time.user`, `Time.mine` for what someone saved). Accounts work only on the venture's own domain: the passkey belongs to that host and the session is a `__Host-` cookie. Every call degrades to null, so a product still works on Pages alone and in the headless checks.
|
||||
- Token: Cloudflare → My Profile → API Tokens → Create Token (custom) with Account · Workers Scripts · Edit, Account · D1 · Edit, Account · Workers AI · Read, and, for a domain, Zone · Workers Routes · Edit and Zone · DNS · Edit on that zone. Then `gh secret set CLOUDFLARE_API_TOKEN` and `gh secret set CLOUDFLARE_ACCOUNT_ID` (the account id is in the dashboard URL).
|
||||
- Domain: `gh variable set TIME_VENTURE_DOMAIN --body example.com` (a zone on that account) puts each venture at `<slug>.example.com`. Without it, ventures live at `time-<slug>.<account>.workers.dev`.
|
||||
- Optional: `TIME_VENTURE_AI_MODEL` (default `@cf/google/gemma-4-26b-a4b-it`).
|
||||
- The doctor checks the token. Existing ventures get backends a couple per tick, and a new `worker.js` (from Time's update) redeploys every venture the same way. A Cloudflare failure never holds a venture: it launches on Pages and the next tick tries again.
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Early access
|
||||
description: Join the early-access list for one of the studio's ventures. No email, nothing charged.
|
||||
title: "Early access: "
|
||||
labels: ["signup"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for raising your hand. This is a **public** GitHub issue: no email is collected and nothing is charged.
|
||||
The studio replies here, and what you write below shapes the next version.
|
||||
- type: input
|
||||
id: venture
|
||||
attributes:
|
||||
label: Venture
|
||||
description: Filled in from the page you came from.
|
||||
placeholder: e.g. mdecks
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: plan
|
||||
attributes:
|
||||
label: Plan
|
||||
description: The tier you would want. Paid tiers are early-access ideas; nothing is charged.
|
||||
placeholder: e.g. Free, or a founding tier
|
||||
- type: textarea
|
||||
id: use
|
||||
attributes:
|
||||
label: What would you use it for?
|
||||
description: One or two sentences about your use. This decides what gets built next.
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Astra: the factory's daily planner. One model call a day that reads the ledger and the board and decides what
|
||||
to work on next. It queues; it never spends on its own beyond its one call.
|
||||
|
||||
python3 .github/time/astra.py tick # daily (astra.yml): read, decide, queue up to a few cards
|
||||
|
||||
Luna is Time's deterministic tick (no model); Sol is the hourly heartbeat that executes cards with a seat; Astra is the
|
||||
slow outer loop that orients them. Astra labels its picks `queued`, never `ready`: the heartbeat's promote_next moves
|
||||
one queued card at a time to `ready` (oldest first, `Depends on #N` respected), so Astra's work lines up behind
|
||||
whatever is already queued instead of running beside it on the same files. Guardrails are code, not prompt: at most
|
||||
TIME_ASTRA_MAX_READY cards queued and TIME_ASTRA_MAX_NEW cards created per run; an existing issue is only queued if it
|
||||
has a `DONE:` line and none of `queued`/`ready`/`needs-human`/`andon`; an open `andon` issue means Astra writes its note
|
||||
and queues nothing; the spend cap is checked by the engine like any other call. Everything Astra does is a comment on
|
||||
one issue, "Astra log", so a human can read the day's reasoning in one place and object there.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
_spec = importlib.util.spec_from_file_location("tm", HERE / "time.py")
|
||||
tm = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(tm)
|
||||
|
||||
SPINE = os.environ.get("TIME_SPINE") or "teahubxyz/teahub" # where AGENTS.md and docs/decisions.md live
|
||||
LOG_TITLE = "Astra log"
|
||||
SKIP = {"queued", "ready", "needs-human", "andon", "astra"} # already moving, a human's, or Astra's own log
|
||||
|
||||
RULES = """You are Astra, the daily planner of a small, fully AI-run software factory (Teahub). Once a day you read the
|
||||
state below and decide what the factory should work on next. You do not do the work: you queue cards, and a worker seat
|
||||
builds queued cards one at a time; you only choose and write cards.
|
||||
|
||||
Rules:
|
||||
- Prefer finishing over starting. Unblock open PRs and half-done cards before opening new work. Cards already labelled
|
||||
`queued` or `ready` are already lined up; don't queue them again.
|
||||
- A card is an issue with a clear list of changes and one `DONE:` line (what must be true for it to be finished, e.g.
|
||||
"DONE: PR open, tests green"). Small, one PR each, a worker can finish in one session.
|
||||
- Never touch workflows, secrets, variables, or anything under docs/decisions.md; those are a human's. Never queue
|
||||
anything that posts outside the repo.
|
||||
- Respect the budget: the day's cap is small. At most {max_ready} cards may be queued and {max_new} created.
|
||||
- If nothing is worth doing, say so; an empty day is fine.
|
||||
|
||||
Answer with JSON only, no prose around it:
|
||||
{{"note": "<3-6 sentences: what the factory did yesterday, what matters today, and why you chose these>",
|
||||
"queue": [<existing open issue numbers to queue now; they must already have a DONE: line>],
|
||||
"new": [{{"title": "<card title>", "body": "<what to change, as a list, ending with a DONE: line>", "queue": true}}]}}"""
|
||||
|
||||
|
||||
def env_int(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(os.environ.get(name) or default)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def spine_file(path: str) -> str:
|
||||
try:
|
||||
import base64
|
||||
return base64.b64decode(tm.gh("GET", f"/repos/{SPINE}/contents/{path}")["content"]).decode(errors="replace")
|
||||
except Exception as e: # noqa: BLE001 the spine is context, not a dependency
|
||||
return f"(could not read {SPINE}/{path}: {str(e)[:80]})"
|
||||
|
||||
|
||||
def board() -> dict:
|
||||
"""What Astra reads: open issues and PRs, yesterday's closed issues, andons, spend."""
|
||||
open_issues = tm.issues("state=open")
|
||||
closed = tm.gh("GET", f"/repos/{tm.REPO}/issues?type=issues&state=closed&limit=20")
|
||||
prs = tm.gh("GET", f"/repos/{tm.REPO}/pulls?state=open&limit=20")
|
||||
today, day_cap, month, month_cap = tm.budget()
|
||||
trim = lambda i: {"n": i["number"], "title": i["title"], "labels": [l["name"] for l in i.get("labels") or []],
|
||||
"has_done": bool(re.search(r"^\s*DONE:", i.get("body") or "", re.M | re.I)),
|
||||
"body": (i.get("body") or "")[:600], "updated": i.get("updated_at")}
|
||||
return {"issues": [trim(i) for i in open_issues if i["title"] != LOG_TITLE],
|
||||
"closed_recently": [{"n": i["number"], "title": i["title"], "closed": i.get("closed_at")} for i in closed[:20]],
|
||||
"prs": [{"n": p["number"], "title": p["title"], "head": p["head"]["ref"], "mergeable": p.get("mergeable")} for p in prs],
|
||||
"andons": [i["number"] for i in open_issues if any(l["name"] == "andon" for l in i.get("labels") or [])],
|
||||
"spend": {"today": round(today, 2), "daily_cap": day_cap, "month": round(month, 2), "monthly_cap": month_cap}}
|
||||
|
||||
|
||||
def log_issue() -> int:
|
||||
for i in tm.issues("state=open"):
|
||||
if i["title"] == LOG_TITLE:
|
||||
return i["number"]
|
||||
return tm.new_issue(LOG_TITLE, "Astra, the daily planner, writes one comment here per day: what it read, what it "
|
||||
"queued, and why. Object in a reply; Astra reads this thread too.", ["astra"])["number"]
|
||||
|
||||
|
||||
def decide(state: dict, context: str, seat: str) -> dict:
|
||||
user = f"Factory context (the spine's AGENTS.md and the newest decisions):\n{context}\n\nBoard:\n{json.dumps(state, indent=1)}"
|
||||
raw = tm.think(seat, None, None, RULES.format(max_ready=env_int("TIME_ASTRA_MAX_READY", 2), max_new=env_int("TIME_ASTRA_MAX_NEW", 2)), user)
|
||||
m = re.search(r"\{.*\}", raw, re.S)
|
||||
try:
|
||||
out = json.loads(m.group(0)) if m else {}
|
||||
except ValueError:
|
||||
out = {}
|
||||
picks = out.get("queue") if out.get("queue") is not None else out.get("ready") # older answers said "ready"
|
||||
return {"note": str(out.get("note") or raw[:800]), "queue": [int(n) for n in picks or [] if str(n).isdigit()],
|
||||
"new": [c for c in out.get("new") or [] if isinstance(c, dict) and c.get("title")]}
|
||||
|
||||
|
||||
def tick() -> int:
|
||||
seat = (os.environ.get("TIME_ASTRA_SEAT") or "opus").strip()
|
||||
max_ready, max_new = env_int("TIME_ASTRA_MAX_READY", 2), env_int("TIME_ASTRA_MAX_NEW", 2)
|
||||
state = board()
|
||||
log = log_issue()
|
||||
if state["andons"]:
|
||||
tm.comment(log, f"🔭 Astra: an andon is open (#{', #'.join(map(str, state['andons']))}), so I'm queuing nothing today. "
|
||||
"Close it once it's handled and I'll plan tomorrow.", {"beat": "astra", "queued": 0, "andon": True})
|
||||
return 0
|
||||
context = spine_file("AGENTS.md")[:6000] + "\n\n" + "\n".join(spine_file("docs/decisions.md").splitlines()[-25:])
|
||||
# the log thread is part of the context: humans object there
|
||||
thread = tm.gh("GET", f"/repos/{tm.REPO}/issues/{log}/comments?limit=20")
|
||||
human = [c["body"][:500] for c in thread if not tm.is_time(c)][-5:]
|
||||
if human:
|
||||
context += "\n\nRecent human replies on the Astra log (follow these):\n- " + "\n- ".join(human)
|
||||
try:
|
||||
plan = decide(state, context, seat)
|
||||
except tm.Budget as e:
|
||||
tm.comment(log, f"🔭 Astra: {e}. No plan today.", {"beat": "astra", "queued": 0, "capped": True})
|
||||
return 0
|
||||
by_n = {i["n"]: i for i in state["issues"]}
|
||||
queued, created, skipped = [], [], []
|
||||
for n in plan["queue"][:max_ready]:
|
||||
i = by_n.get(n)
|
||||
if not i or not i["has_done"] or SKIP & set(i["labels"]):
|
||||
skipped.append(n)
|
||||
continue
|
||||
tm.label(n, "queued")
|
||||
queued.append(n)
|
||||
for c in plan["new"][:max_new]:
|
||||
body = str(c.get("body") or "")
|
||||
if not re.search(r"^\s*DONE:", body, re.M | re.I):
|
||||
body += "\n\nDONE: PR open that does the above, tests green."
|
||||
issue = tm.new_issue(str(c["title"])[:200], body + "\n\n<sub>card written by Astra</sub>", [])
|
||||
created.append(issue["number"])
|
||||
if c.get("queue", c.get("ready", True)) and len(queued) + len(created) <= max_ready:
|
||||
tm.label(issue["number"], "queued") # the heartbeat promotes it when nothing else is in flight
|
||||
lines = [f"🔭 Astra ({seat}), spend ${state['spend']['today']:.2f} of ${state['spend']['daily_cap']:.2f} today:", "", plan["note"], ""]
|
||||
if queued:
|
||||
lines.append("Queued: " + ", ".join(f"#{n}" for n in queued))
|
||||
if created:
|
||||
lines.append("New cards: " + ", ".join(f"#{n}" for n in created))
|
||||
if skipped:
|
||||
lines.append("Skipped (no DONE line, already moving, or a human's): " + ", ".join(f"#{n}" for n in skipped))
|
||||
if not (queued or created):
|
||||
lines.append("Queued nothing today.")
|
||||
tm.comment(log, "\n".join(lines), {"beat": "astra", "queued": len(queued) + len(created), "queue": queued, "new": created})
|
||||
print(f"astra: queued={queued} new={created} skipped={skipped}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(tick() if sys.argv[1:2] == ["tick"] else __doc__)
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Cloudflare backing for ventures: each venture gets a domain, a Worker, a D1 database and Workers AI.
|
||||
|
||||
python3 .github/time/cloudflare.py check # what the token can do (doctor)
|
||||
python3 .github/time/cloudflare.py deploy <slug> <pages-folder-url>
|
||||
|
||||
Off unless CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are set. The page stays on GitHub Pages; the Worker
|
||||
(cloudflare/worker.js, one script `time-<slug>` per venture) serves it under the venture's own domain and adds the API
|
||||
the page's time.js client calls: share links stored in D1, and a rate-limited small model. With TIME_VENTURE_DOMAIN set
|
||||
(a zone on the same Cloudflare account) a venture lives at <slug>.<domain>; without it, at the script's workers.dev URL.
|
||||
|
||||
Everything here is idempotent: deploying again updates the script in place and reuses the database and the domain.
|
||||
The deployed revision (REV) is a hash of the worker code and its settings, so the venture loop can redeploy every
|
||||
venture when Time's update brings a new worker, and leave them alone otherwise.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
API = "https://api.cloudflare.com/client/v4"
|
||||
HERE = Path(__file__).resolve().parent
|
||||
WORKER = HERE / "cloudflare" / "worker.js"
|
||||
CLIENT = HERE / "cloudflare" / "time.js"
|
||||
COMPAT = "2026-09-01"
|
||||
AI_MODEL = "@cf/google/gemma-4-26b-a4b-it"
|
||||
|
||||
|
||||
def env(name: str, default: str = "") -> str:
|
||||
return (os.environ.get(name) or default).strip()
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
return bool(env("CLOUDFLARE_API_TOKEN") and env("CLOUDFLARE_ACCOUNT_ID"))
|
||||
|
||||
|
||||
def account() -> str:
|
||||
return f"/accounts/{env('CLOUDFLARE_ACCOUNT_ID')}"
|
||||
|
||||
|
||||
def cf(method: str, path: str, body=None, *, data: bytes | None = None, ctype: str = "application/json"):
|
||||
"""One Cloudflare API call; returns `result`. Errors name the call and Cloudflare's own messages, never the token."""
|
||||
raw = data if data is not None else (json.dumps(body).encode() if body is not None else None)
|
||||
req = urllib.request.Request(API + path, method=method, data=raw,
|
||||
headers={"Authorization": f"Bearer {env('CLOUDFLARE_API_TOKEN')}", "Content-Type": ctype})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
out = json.loads(r.read() or b"{}")
|
||||
except urllib.error.HTTPError as e:
|
||||
text = e.read().decode(errors="replace")
|
||||
try:
|
||||
msgs = "; ".join(f"{x.get('code')} {x.get('message')}" for x in json.loads(text).get("errors", []))
|
||||
except ValueError:
|
||||
msgs = text[:200]
|
||||
raise RuntimeError(f"cloudflare {method} {path.split('?')[0].replace(env('CLOUDFLARE_ACCOUNT_ID'), '<account>')} "
|
||||
f"-> {e.code}: {msgs}") from None
|
||||
return out.get("result")
|
||||
|
||||
|
||||
def script_name(slug: str) -> str:
|
||||
return re.sub(r"[^a-z0-9-]", "-", f"time-{slug}".lower())[:63].strip("-")
|
||||
|
||||
|
||||
def host(slug: str) -> str | None:
|
||||
zone = env("TIME_VENTURE_DOMAIN").lower().strip(".")
|
||||
return f"{slug}.{zone}" if zone else None
|
||||
|
||||
|
||||
def settings(slug: str, origin: str, pages_origin: str) -> dict:
|
||||
return {"SLUG": slug, "ORIGIN": origin.rstrip("/"), "PAGES_ORIGIN": pages_origin,
|
||||
"AI_MODEL": env("TIME_VENTURE_AI_MODEL", AI_MODEL), "AI_DAILY": env("TIME_VENTURE_AI_DAILY", "200"),
|
||||
"SAVE_DAILY": env("TIME_VENTURE_SAVE_DAILY", "2000")}
|
||||
|
||||
|
||||
def rev(vars_: dict) -> str:
|
||||
"""The revision a deploy would make: worker code + settings + domain. Equal revs mean nothing to do."""
|
||||
h = hashlib.sha256(WORKER.read_bytes())
|
||||
h.update(json.dumps({**vars_, "host": host(vars_["SLUG"]), "compat": COMPAT}, sort_keys=True).encode())
|
||||
return h.hexdigest()[:12]
|
||||
|
||||
|
||||
def database(slug: str) -> str:
|
||||
"""The venture's own D1 database (created once). Returns its id."""
|
||||
name = script_name(slug)
|
||||
for d in cf("GET", f"{account()}/d1/database?name={name}") or []:
|
||||
if d.get("name") == name:
|
||||
return d["uuid"]
|
||||
return cf("POST", f"{account()}/d1/database", {"name": name})["uuid"]
|
||||
|
||||
|
||||
def multipart(parts: list[tuple[str, str, bytes]]) -> tuple[bytes, str]:
|
||||
b = uuid.uuid4().hex
|
||||
out = b"".join(f'--{b}\r\nContent-Disposition: form-data; name="{n}"; filename="{n}"\r\nContent-Type: {t}\r\n\r\n'.encode()
|
||||
+ data + b"\r\n" for n, t, data in parts) + f"--{b}--\r\n".encode()
|
||||
return out, f"multipart/form-data; boundary={b}"
|
||||
|
||||
|
||||
def upload(name: str, db: str, vars_: dict) -> None:
|
||||
meta = {"main_module": "worker.js", "compatibility_date": COMPAT,
|
||||
"bindings": [{"type": "d1", "name": "DB", "id": db}, {"type": "ai", "name": "AI"}]
|
||||
+ [{"type": "plain_text", "name": k, "text": str(v)} for k, v in vars_.items()]}
|
||||
data, ctype = multipart([("metadata", "application/json", json.dumps(meta).encode()),
|
||||
("worker.js", "application/javascript+module", WORKER.read_bytes())])
|
||||
cf("PUT", f"{account()}/workers/scripts/{name}", data=data, ctype=ctype)
|
||||
|
||||
|
||||
def attach(name: str, hostname: str) -> str:
|
||||
"""Point <slug>.<domain> at the script as a Workers custom domain (Cloudflare adds the DNS record and the cert)."""
|
||||
zone = hostname.split(".", 1)[1]
|
||||
zones = cf("GET", f"/zones?name={zone}") or []
|
||||
if not zones:
|
||||
raise RuntimeError(f"TIME_VENTURE_DOMAIN {zone} is not a zone this Cloudflare token can see")
|
||||
cf("PUT", f"{account()}/workers/domains",
|
||||
{"hostname": hostname, "service": name, "zone_id": zones[0]["id"], "environment": "production"})
|
||||
return f"https://{hostname}/"
|
||||
|
||||
|
||||
def workers_dev(name: str) -> str:
|
||||
cf("POST", f"{account()}/workers/scripts/{name}/subdomain", {"enabled": True, "previews_enabled": False})
|
||||
sub = (cf("GET", f"{account()}/workers/subdomain") or {}).get("subdomain")
|
||||
if not sub:
|
||||
raise RuntimeError("this Cloudflare account has no workers.dev subdomain yet: set TIME_VENTURE_DOMAIN instead")
|
||||
return f"https://{name}.{sub}.workers.dev/"
|
||||
|
||||
|
||||
def pages_origin_of(url: str) -> str:
|
||||
m = re.match(r"(https?://[^/]+)", url)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
def deploy(slug: str, origin: str) -> dict:
|
||||
"""Database, script and address for one venture. Returns {url, worker, db, rev, api}."""
|
||||
vars_ = settings(slug, origin, pages_origin_of(origin))
|
||||
vars_["REV"] = r = rev(vars_)
|
||||
name = script_name(slug)
|
||||
db = database(slug)
|
||||
upload(name, db, vars_)
|
||||
hostname = host(slug)
|
||||
url = attach(name, hostname) if hostname else workers_dev(name)
|
||||
return {"url": url, "worker": name, "db": db, "rev": r, "api": url + "api/"}
|
||||
|
||||
|
||||
def client(api: str) -> str:
|
||||
"""The time.js a venture page loads, pointed at its backend."""
|
||||
return CLIENT.read_text().replace("__TIME_API__", api)
|
||||
|
||||
|
||||
def check() -> tuple[bool, str]:
|
||||
"""Can this token do what a venture needs? For the doctor: (ok, detail), with nothing secret in the detail."""
|
||||
if not enabled():
|
||||
return False, "not set (optional): CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID give ventures domains, a database and AI"
|
||||
try:
|
||||
cf("GET", f"{account()}/workers/scripts")
|
||||
cf("GET", f"{account()}/d1/database?per_page=1")
|
||||
zone = env("TIME_VENTURE_DOMAIN")
|
||||
if zone and not cf("GET", f"/zones?name={zone}"):
|
||||
return False, f"works, but TIME_VENTURE_DOMAIN {zone} is not a zone this token can see"
|
||||
return True, f"works: Workers and D1; ventures live at {'<slug>.' + zone if zone else 'workers.dev'}"
|
||||
except Exception as e: # noqa: BLE001
|
||||
return False, f"set but failing: {str(e)[:160]}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.argv[1:2] == ["check"]:
|
||||
ok, detail = check()
|
||||
print(detail)
|
||||
sys.exit(0 if ok else 1)
|
||||
if sys.argv[1:2] == ["deploy"] and len(sys.argv) == 4:
|
||||
print(json.dumps(deploy(sys.argv[2], sys.argv[3]), indent=1))
|
||||
sys.exit(0)
|
||||
sys.exit(__doc__)
|
||||
@@ -0,0 +1,187 @@
|
||||
// Offline test for passkey accounts in worker.js: a real SQLite standing in for D1 (node:sqlite) and a software
|
||||
// authenticator with real P-256 and RSA keys. Run: node .github/time/cloudflare/test_auth.mjs
|
||||
// Skips (and says so) when this node has no node:sqlite.
|
||||
import assert from "node:assert/strict";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
let DatabaseSync;
|
||||
try {
|
||||
process.removeAllListeners("warning");
|
||||
({ DatabaseSync } = await import("node:sqlite"));
|
||||
} catch {
|
||||
console.log("AUTH SKIPPED: no node:sqlite");
|
||||
process.exit(0);
|
||||
}
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const worker = (await import(join(here, "worker.js"))).default;
|
||||
|
||||
// D1's surface over SQLite: prepare/bind/run/first/all, and batch as one transaction.
|
||||
class D1 {
|
||||
constructor() { this.db = new DatabaseSync(":memory:"); }
|
||||
prepare(sql) {
|
||||
const db = this.db;
|
||||
const make = (args) => ({
|
||||
bind: (...a) => make(a),
|
||||
exec: () => ({ results: db.prepare(sql).all(...args) }),
|
||||
run: async () => ({ results: db.prepare(sql).all(...args) }),
|
||||
first: async () => db.prepare(sql).all(...args)[0] ?? null,
|
||||
all: async () => ({ results: db.prepare(sql).all(...args) }),
|
||||
});
|
||||
return make([]);
|
||||
}
|
||||
async batch(stmts) {
|
||||
this.db.exec("BEGIN");
|
||||
try {
|
||||
const out = stmts.map((s) => s.exec());
|
||||
this.db.exec("COMMIT");
|
||||
return out;
|
||||
} catch (e) {
|
||||
this.db.exec("ROLLBACK");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const HOST = "mdecks.yolo.lol", ORIGIN = `https://${HOST}`;
|
||||
const env = { DB: new D1(), SLUG: "mdecks", REV: "r", ORIGIN: "https://o.github.io/r/ventures/mdecks/", PAGES_ORIGIN: "https://o.github.io" };
|
||||
const enc = new TextEncoder();
|
||||
const b64u = (buf) => Buffer.from(buf).toString("base64url");
|
||||
const unb64u = (s) => new Uint8Array(Buffer.from(s, "base64url"));
|
||||
const sha256 = async (d) => new Uint8Array(await crypto.subtle.digest("SHA-256", typeof d === "string" ? enc.encode(d) : d));
|
||||
let ip = 0;
|
||||
const call = (path, { body, cookie, origin, host = HOST } = {}) => worker.fetch(new Request(`https://${host}${path}`, {
|
||||
method: body === undefined ? "GET" : "POST", body: body === undefined ? undefined : JSON.stringify(body),
|
||||
headers: { "content-type": "application/json", "cf-connecting-ip": `10.0.0.${++ip % 250}`, ...(cookie ? { cookie } : {}),
|
||||
...(origin ? { origin } : {}) } }), env);
|
||||
const cookieOf = (r) => (r.headers.get("set-cookie") || "").split(";")[0];
|
||||
|
||||
// The software authenticator: what a phone or a laptop's passkey would hand the page.
|
||||
function authData(rpHash, flags, counter) {
|
||||
const a = new Uint8Array(37);
|
||||
a.set(rpHash);
|
||||
a[32] = flags;
|
||||
new DataView(a.buffer).setUint32(33, counter);
|
||||
return a;
|
||||
}
|
||||
function rawToDer(raw) {
|
||||
const int = (b) => {
|
||||
let v = [...b];
|
||||
while (v.length > 1 && v[0] === 0 && !(v[1] & 0x80)) v.shift();
|
||||
if (v[0] & 0x80) v = [0, ...v];
|
||||
return [0x02, v.length, ...v];
|
||||
};
|
||||
const body = [...int(raw.slice(0, 32)), ...int(raw.slice(32))];
|
||||
return new Uint8Array([0x30, body.length, ...body]);
|
||||
}
|
||||
class Authenticator {
|
||||
static async make(kind = "ES256") {
|
||||
const a = new Authenticator();
|
||||
a.kind = kind;
|
||||
a.alg = kind === "ES256" ? -7 : -257;
|
||||
a.keys = kind === "ES256"
|
||||
? await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"])
|
||||
: await crypto.subtle.generateKey({ name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: "SHA-256" }, true, ["sign", "verify"]);
|
||||
a.id = b64u(crypto.getRandomValues(new Uint8Array(16)));
|
||||
a.counter = 0;
|
||||
return a;
|
||||
}
|
||||
async create(options, { origin = ORIGIN } = {}) {
|
||||
const clientData = enc.encode(JSON.stringify({ type: "webauthn.create", challenge: options.publicKey.challenge, origin }));
|
||||
return { challengeId: options.challengeId, id: this.id, alg: this.alg, clientDataJSON: b64u(clientData),
|
||||
authenticatorData: b64u(authData(await sha256(options.publicKey.rp.id), 0x45, this.counter)),
|
||||
publicKey: b64u(await crypto.subtle.exportKey("spki", this.keys.publicKey)) };
|
||||
}
|
||||
async get(options, { origin = ORIGIN, counter = ++this.counter, rpId = options.publicKey.rpId } = {}) {
|
||||
const clientData = enc.encode(JSON.stringify({ type: "webauthn.get", challenge: options.publicKey.challenge, origin }));
|
||||
const ad = authData(await sha256(rpId), 0x05, counter);
|
||||
const signed = new Uint8Array([...ad, ...(await sha256(clientData))]);
|
||||
const algo = this.kind === "ES256" ? { name: "ECDSA", hash: "SHA-256" } : { name: "RSASSA-PKCS1-v1_5" };
|
||||
let sig = new Uint8Array(await crypto.subtle.sign(algo, this.keys.privateKey, signed));
|
||||
if (this.kind === "ES256") sig = rawToDer(sig);
|
||||
return { challengeId: options.challengeId, id: this.id, clientDataJSON: b64u(clientData), authenticatorData: b64u(ad),
|
||||
signature: b64u(sig) };
|
||||
}
|
||||
}
|
||||
|
||||
// sign up: options for this host, then the passkey's answer makes the account and a session
|
||||
const key = await Authenticator.make();
|
||||
let r = await call("/api/auth/register/start", { body: { name: "Ada" } });
|
||||
let opts = await r.json();
|
||||
assert.equal(opts.publicKey.rp.id, HOST);
|
||||
assert.equal(opts.publicKey.user.name, "Ada");
|
||||
assert.deepEqual(opts.publicKey.pubKeyCredParams.map((p) => p.alg), [-7, -257]);
|
||||
r = await call("/api/auth/register/finish", { body: await key.create(opts) });
|
||||
assert.equal(r.status, 200, await r.clone().text());
|
||||
const ada = (await r.json()).user;
|
||||
assert.equal(ada.name, "Ada");
|
||||
let cookie = cookieOf(r);
|
||||
assert.match(r.headers.get("set-cookie"), /^__Host-time=[A-Za-z0-9_-]+; Path=\/; Secure; HttpOnly; SameSite=Lax; Max-Age=2592000$/);
|
||||
|
||||
// the session knows who it is, and keeps what they save
|
||||
assert.deepEqual((await (await call("/api/auth/me", { cookie })).json()).user, ada);
|
||||
r = await call("/api/items", { body: { deck: "# mine" }, cookie });
|
||||
const mine = (await r.json()).id;
|
||||
await call("/api/items", { body: { deck: "# someone else's" } });
|
||||
r = await call("/api/me/items", { cookie });
|
||||
assert.deepEqual((await r.json()).items.map((i) => i.id), [mine]);
|
||||
assert.equal((await call("/api/me/items")).status, 401);
|
||||
|
||||
// sign out, then sign back in with the same passkey (discoverable: no username asked)
|
||||
r = await call("/api/auth/logout", { body: {}, cookie });
|
||||
assert.match(r.headers.get("set-cookie"), /Max-Age=0/);
|
||||
assert.equal((await call("/api/auth/me", { cookie })).status, 401);
|
||||
opts = await (await call("/api/auth/login/start", { body: {} })).json();
|
||||
assert.equal(opts.publicKey.rpId, HOST);
|
||||
const answer = await key.get(opts);
|
||||
r = await call("/api/auth/login/finish", { body: answer });
|
||||
assert.equal(r.status, 200, await r.clone().text());
|
||||
cookie = cookieOf(r);
|
||||
assert.deepEqual((await (await call("/api/auth/me", { cookie })).json()).user, ada);
|
||||
assert.deepEqual((await (await call("/api/me/items", { cookie })).json()).items.map((i) => i.id), [mine]);
|
||||
|
||||
// what must fail
|
||||
const fails = async (path, body, why) => {
|
||||
const res = await call(path, { body });
|
||||
assert.equal(res.status, 400, `${why}: ${res.status}`);
|
||||
return (await res.json()).error;
|
||||
};
|
||||
assert.match(await fails("/api/auth/login/finish", answer, "replayed answer"), /expired/);
|
||||
let o = await (await call("/api/auth/login/start", { body: {} })).json();
|
||||
assert.match(await fails("/api/auth/login/finish", await key.get(o, { origin: "https://evil.example" }), "phishing origin"), /origin/);
|
||||
o = await (await call("/api/auth/login/start", { body: {} })).json();
|
||||
assert.match(await fails("/api/auth/login/finish", await key.get(o, { rpId: "evil.example" }), "other site's passkey"), /relying party/);
|
||||
o = await (await call("/api/auth/login/start", { body: {} })).json();
|
||||
assert.match(await fails("/api/auth/login/finish", await key.get(o, { counter: 1 }), "counter went back"), /cloned/);
|
||||
o = await (await call("/api/auth/login/start", { body: {} })).json();
|
||||
const forged = await key.get(o);
|
||||
const bad = unb64u(forged.signature);
|
||||
bad[bad.length - 1] ^= 1;
|
||||
assert.match(await fails("/api/auth/login/finish", { ...forged, signature: b64u(bad) }, "tampered signature"), /signature/);
|
||||
o = await (await call("/api/auth/login/start", { body: {} })).json();
|
||||
const stranger = await Authenticator.make();
|
||||
assert.match(await fails("/api/auth/login/finish", await stranger.get(o), "unregistered passkey"), /unknown passkey/);
|
||||
o = await (await call("/api/auth/register/start", { body: { name: "Again" } })).json();
|
||||
assert.match(await fails("/api/auth/register/finish", await key.create(o), "same passkey twice"), /already registered/);
|
||||
o = await (await call("/api/auth/register/start", { body: { name: "Eve" } })).json();
|
||||
assert.match(await fails("/api/auth/register/finish", await stranger.create(o, { origin: "https://o.github.io" }), "from Pages"), /origin/);
|
||||
assert.equal((await call("/api/auth/login/start", { body: {}, origin: "https://o.github.io" })).status, 403);
|
||||
assert.equal((await call("/api/auth/me", { cookie: "__Host-time=forged" })).status, 401);
|
||||
// a failed sign-up left no half-made account behind
|
||||
assert.equal(env.DB.db.prepare("SELECT COUNT(*) AS n FROM users").all()[0].n, 1);
|
||||
|
||||
// RSA passkeys (RS256, some Windows Hello setups) work too
|
||||
const rsa = await Authenticator.make("RS256");
|
||||
o = await (await call("/api/auth/register/start", { body: { name: "Grace" } })).json();
|
||||
r = await call("/api/auth/register/finish", { body: await rsa.create(o) });
|
||||
assert.equal(r.status, 200, await r.clone().text());
|
||||
o = await (await call("/api/auth/login/start", { body: {} })).json();
|
||||
r = await call("/api/auth/login/finish", { body: await rsa.get(o) });
|
||||
assert.equal((await r.json()).user.name, "Grace");
|
||||
|
||||
// the other endpoints still work on a database that has accounts
|
||||
r = await call("/api/health");
|
||||
assert.equal((await r.json()).ok, true);
|
||||
|
||||
console.log("AUTH OK");
|
||||
@@ -0,0 +1,113 @@
|
||||
// Offline test for worker.js: a fake D1 (just the statements the worker uses), fake Workers AI and a fake Pages origin.
|
||||
// Run: node .github/time/cloudflare/test_worker.mjs (test_time.py runs it when node is around)
|
||||
import assert from "node:assert/strict";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const worker = (await import(join(here, "worker.js"))).default;
|
||||
|
||||
class FakeD1 {
|
||||
constructor() { this.items = new Map(); this.quota = new Map(); this.created = 0; }
|
||||
prepare(sql) {
|
||||
const db = this;
|
||||
const stmt = {
|
||||
args: [],
|
||||
bind(...a) { return { ...stmt, args: a }; },
|
||||
async run() { return this.exec(); },
|
||||
async first() { return (await this.exec()).results[0] ?? null; },
|
||||
async exec() {
|
||||
const a = this.args;
|
||||
if (sql.startsWith("CREATE TABLE")) { db.created++; return { results: [] }; }
|
||||
if (sql.startsWith("INSERT INTO quota")) {
|
||||
const n = (db.quota.get(a[0]) || 0) + 1; db.quota.set(a[0], n); return { results: [{ n }] };
|
||||
}
|
||||
if (sql.startsWith("INSERT INTO items")) { db.items.set(a[0], a[1]); return { results: [] }; }
|
||||
if (sql.startsWith("SELECT data FROM items")) { return { results: db.items.has(a[0]) ? [{ data: db.items.get(a[0]) }] : [] }; }
|
||||
throw new Error("unexpected sql " + sql);
|
||||
},
|
||||
};
|
||||
return stmt;
|
||||
}
|
||||
async batch(stmts) { return Promise.all(stmts.map((s) => s.exec())); }
|
||||
}
|
||||
|
||||
const prompts = [];
|
||||
const env = {
|
||||
DB: new FakeD1(), SLUG: "mdecks", REV: "abc123", ORIGIN: "https://o.github.io/r/ventures/mdecks/", PAGES_ORIGIN: "https://o.github.io",
|
||||
AI_MODEL: "@cf/test/model", AI_DAILY: "3", SAVE_DAILY: "100",
|
||||
AI: { async run(model, input) { prompts.push({ model, input }); return { response: "a crisp title" }; } },
|
||||
};
|
||||
const pages = [];
|
||||
globalThis.fetch = async (url, init) => {
|
||||
pages.push({ url, init });
|
||||
if (url.endsWith("/ventures/mdecks/shots")) return new Response(null, { status: 301, headers: { location: "https://o.github.io/r/ventures/mdecks/shots/" } });
|
||||
return new Response(`<html>${url}</html>`, { status: 200, headers: { "content-type": "text/html" } });
|
||||
};
|
||||
const call = (path, init = {}, ip = "1.1.1.1") =>
|
||||
worker.fetch(new Request("https://mdecks.yolo.lol" + path, { ...init, headers: { "cf-connecting-ip": ip, ...(init.headers || {}) } }), env);
|
||||
const post = (path, body, ip, headers = {}) =>
|
||||
call(path, { method: "POST", body: typeof body === "string" ? body : JSON.stringify(body), headers: { "content-type": "application/json", ...headers } }, ip);
|
||||
|
||||
// health, without touching the database
|
||||
let r = await call("/api/health");
|
||||
assert.deepEqual(await r.json(), { ok: true, slug: "mdecks", rev: "abc123", ai: true });
|
||||
assert.equal(env.DB.created, 0);
|
||||
|
||||
// save and load a creation; the link comes back to this domain
|
||||
r = await post("/api/items", { slides: ["# Hi"] });
|
||||
assert.equal(r.status, 201);
|
||||
const saved = await r.json();
|
||||
assert.match(saved.id, /^[a-f0-9]{12}$/);
|
||||
assert.equal(saved.link, `https://mdecks.yolo.lol/?s=${saved.id}`);
|
||||
assert.equal(env.DB.created, 7); // every table, created once
|
||||
r = await call(`/api/items/${saved.id}`);
|
||||
assert.deepEqual(await r.json(), { slides: ["# Hi"] });
|
||||
assert.equal((await call("/api/items/doesnotexist1")).status, 404);
|
||||
assert.equal((await post("/api/items", "not json")).status, 400);
|
||||
assert.equal((await post("/api/items", JSON.stringify({ x: "y".repeat(70000) }))).status, 413);
|
||||
|
||||
// AI through Workers AI with the configured model; per-person and daily limits
|
||||
r = await post("/api/ai", { prompt: "title for my deck", system: "be brief" }, "2.2.2.2");
|
||||
assert.deepEqual(await r.json(), { text: "a crisp title" });
|
||||
assert.equal(prompts[0].model, "@cf/test/model");
|
||||
assert.deepEqual(prompts[0].input.messages.map((m) => m.role), ["system", "user"]);
|
||||
assert.equal((await post("/api/ai", { prompt: "" }, "2.2.2.2")).status, 400);
|
||||
assert.equal((await post("/api/ai", { prompt: "two" }, "3.3.3.3")).status, 200);
|
||||
assert.equal((await post("/api/ai", { prompt: "three" }, "4.4.4.4")).status, 200);
|
||||
assert.equal((await post("/api/ai", { prompt: "over the day" }, "5.5.5.5")).status, 429);
|
||||
assert.equal(prompts.length, 3);
|
||||
|
||||
// newer models answer in the OpenAI shape
|
||||
const openai = { ...env, DB: new FakeD1(), AI: { async run() { return { choices: [{ message: { content: "from choices" } }] }; } } };
|
||||
r = await worker.fetch(new Request("https://mdecks.yolo.lol/api/ai", { method: "POST", body: JSON.stringify({ prompt: "hi" }),
|
||||
headers: { "cf-connecting-ip": "9.9.9.9" } }), openai);
|
||||
assert.deepEqual(await r.json(), { text: "from choices" });
|
||||
|
||||
// CORS: this domain and the Pages origin, nobody else
|
||||
r = await call("/api/health", { headers: { origin: "https://o.github.io" } });
|
||||
assert.equal(r.headers.get("access-control-allow-origin"), "https://o.github.io");
|
||||
r = await call("/api/health", { headers: { origin: "https://evil.example" } });
|
||||
assert.equal(r.headers.get("access-control-allow-origin"), null);
|
||||
assert.equal((await call("/api/nope")).status, 404);
|
||||
|
||||
// a broken database answers 500 with JSON, not an exception
|
||||
const broken = { ...env, DB: { prepare() { throw new Error("D1 down"); }, batch() { return Promise.reject(new Error("D1 down")); } } };
|
||||
r = await worker.fetch(new Request("https://x.yolo.lol/api/items", { method: "POST", body: "{}" }), broken);
|
||||
assert.equal(r.status, 500);
|
||||
assert.match((await r.json()).detail, /D1 down/);
|
||||
|
||||
// the page: Pages under this domain, query kept, redirects kept on this host
|
||||
r = await call("/?s=" + saved.id);
|
||||
assert.equal(pages.at(-1).url, "https://o.github.io/r/ventures/mdecks/?s=" + saved.id);
|
||||
assert.equal(r.headers.get("x-time-venture"), "mdecks");
|
||||
assert.match(await r.text(), /ventures\/mdecks\//);
|
||||
r = await call("/app.js");
|
||||
assert.equal(pages.at(-1).url, "https://o.github.io/r/ventures/mdecks/app.js");
|
||||
r = await call("");
|
||||
assert.equal(pages.at(-1).url, "https://o.github.io/r/ventures/mdecks/");
|
||||
r = await call("/shots");
|
||||
assert.equal(r.status, 301);
|
||||
assert.equal(r.headers.get("location"), "/shots/");
|
||||
|
||||
console.log("WORKER OK");
|
||||
@@ -13,7 +13,7 @@
|
||||
Time.signOut() -> true
|
||||
Time.mine() -> [{ id, link, created }] the signed-in person saved, or null */
|
||||
(function () {
|
||||
var API = "https://mdecks.yolopreneur.lol/api/";
|
||||
var API = "__TIME_API__";
|
||||
var local = /^(localhost|127\.|\[::1\]|0\.0\.0\.0)/.test(location.hostname) || location.protocol === "file:";
|
||||
var home = API.replace(/\/api\/?$/, "");
|
||||
var accounts = !local && location.origin === home.replace(/\/$/, "") && !!window.PublicKeyCredential;
|
||||
@@ -0,0 +1,308 @@
|
||||
// A venture's backend on Cloudflare: its own domain in front of its GitHub Pages folder, plus a small API.
|
||||
// Time deploys one copy per venture (script time-<slug>) with its own D1 database; see .github/time/cloudflare.py.
|
||||
//
|
||||
// GET /api/health -> { ok, slug, rev, ai }
|
||||
// POST /api/items -> { id, link } save a JSON creation (<= 64 KB), for share links
|
||||
// GET /api/items/:id -> the saved JSON
|
||||
// POST /api/ai -> { text } { prompt, system? } through Workers AI, rate limited
|
||||
// accounts, with passkeys (WebAuthn): no passwords, no email, nothing to configure
|
||||
// POST /api/auth/register/start { name } -> { challengeId, publicKey } options for navigator.credentials.create
|
||||
// POST /api/auth/register/finish -> { user } sets the session cookie
|
||||
// POST /api/auth/login/start -> { challengeId, publicKey } options for navigator.credentials.get
|
||||
// POST /api/auth/login/finish -> { user } sets the session cookie
|
||||
// GET /api/auth/me -> { user } or 401 POST /api/auth/logout
|
||||
// GET /api/me/items -> { items } what the signed-in person saved (POST /api/items records the owner)
|
||||
// anything else -> the venture's page on GitHub Pages (ORIGIN), served under this domain
|
||||
//
|
||||
// Accounts live on the venture's own domain: the passkey's relying party is this host, and the session is a
|
||||
// __Host- cookie (HttpOnly, Secure, SameSite=Lax). On GitHub Pages or in the checks there is no session, by design.
|
||||
//
|
||||
// Bindings: DB (D1), AI (Workers AI). Vars: SLUG, ORIGIN, PAGES_ORIGIN, REV, AI_MODEL, AI_DAILY, SAVE_DAILY.
|
||||
|
||||
const SCHEMA = [
|
||||
"CREATE TABLE IF NOT EXISTS items (id TEXT PRIMARY KEY, data TEXT NOT NULL, created TEXT NOT NULL)",
|
||||
"CREATE TABLE IF NOT EXISTS quota (k TEXT PRIMARY KEY, n INTEGER NOT NULL)",
|
||||
"CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, name TEXT NOT NULL, created TEXT NOT NULL)",
|
||||
"CREATE TABLE IF NOT EXISTS creds (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, spki TEXT NOT NULL, alg INTEGER NOT NULL, " +
|
||||
"counter INTEGER NOT NULL, created TEXT NOT NULL)",
|
||||
"CREATE TABLE IF NOT EXISTS sessions (hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires INTEGER NOT NULL)",
|
||||
"CREATE TABLE IF NOT EXISTS challenges (id TEXT PRIMARY KEY, challenge TEXT NOT NULL, kind TEXT NOT NULL, user_id TEXT, " +
|
||||
"name TEXT, expires INTEGER NOT NULL)",
|
||||
"CREATE TABLE IF NOT EXISTS owned (item_id TEXT PRIMARY KEY, user_id TEXT NOT NULL, created TEXT NOT NULL)",
|
||||
];
|
||||
const COOKIE = "__Host-time";
|
||||
const SESSION_DAYS = 30;
|
||||
const CHALLENGE_MS = 5 * 60 * 1000;
|
||||
const MAX_ITEM = 65536;
|
||||
const MAX_PROMPT = 4000;
|
||||
let ready = null;
|
||||
|
||||
const json = (body, status = 200, headers = {}) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8", ...headers } });
|
||||
|
||||
function cors(req, env) {
|
||||
const origin = req.headers.get("origin");
|
||||
if (!origin) return {};
|
||||
const self = new URL(req.url).origin;
|
||||
const ok = origin === self || (env.PAGES_ORIGIN && origin === env.PAGES_ORIGIN);
|
||||
return ok ? { "access-control-allow-origin": origin, "access-control-allow-methods": "GET, POST",
|
||||
"access-control-allow-headers": "content-type", "vary": "origin" } : {};
|
||||
}
|
||||
|
||||
async function setup(env) {
|
||||
ready ??= env.DB.batch(SCHEMA.map((s) => env.DB.prepare(s))).catch((e) => { ready = null; throw e; });
|
||||
return ready;
|
||||
}
|
||||
|
||||
async function who(req, env) {
|
||||
const ip = req.headers.get("cf-connecting-ip") || "?";
|
||||
const raw = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${env.SLUG}:${ip}`));
|
||||
return [...new Uint8Array(raw).slice(0, 8)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
// One counter per (day, kind, person) and one per (day, kind): over either limit means no.
|
||||
async function spend(env, kind, person, perPerson, total) {
|
||||
const day = new Date().toISOString().slice(0, 10);
|
||||
const bump = (k) => env.DB.prepare("INSERT INTO quota (k, n) VALUES (?, 1) ON CONFLICT(k) DO UPDATE SET n = n + 1 RETURNING n").bind(k);
|
||||
const [mine, all] = await env.DB.batch([bump(`${day}:${kind}:${person}`), bump(`${day}:${kind}:*`)]);
|
||||
return mine.results[0].n <= perPerson && all.results[0].n <= total;
|
||||
}
|
||||
|
||||
// ---------- passkeys (WebAuthn) ----------
|
||||
|
||||
const enc = new TextEncoder();
|
||||
const b64u = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
const unb64u = (s) => Uint8Array.from(atob(String(s).replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
|
||||
const random = (n) => crypto.getRandomValues(new Uint8Array(n));
|
||||
const sha256 = async (data) => new Uint8Array(await crypto.subtle.digest("SHA-256", typeof data === "string" ? enc.encode(data) : data));
|
||||
const same = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
|
||||
const ALGS = {
|
||||
"-7": { importAs: { name: "ECDSA", namedCurve: "P-256" }, verifyAs: { name: "ECDSA", hash: "SHA-256" }, der: true },
|
||||
"-257": { importAs: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, verifyAs: { name: "RSASSA-PKCS1-v1_5" }, der: false },
|
||||
};
|
||||
|
||||
// Authenticators sign ES256 as DER (SEQUENCE of two INTEGERs); WebCrypto wants r || s, 32 bytes each.
|
||||
function derToRaw(der) {
|
||||
let i = 2;
|
||||
if (der[1] & 0x80) i += der[1] & 0x7f;
|
||||
const part = () => {
|
||||
if (der[i++] !== 0x02) throw new Error("bad signature");
|
||||
const len = der[i++];
|
||||
let v = der.slice(i, i + len);
|
||||
i += len;
|
||||
while (v.length > 32 && v[0] === 0) v = v.slice(1);
|
||||
const out = new Uint8Array(32);
|
||||
out.set(v, 32 - v.length);
|
||||
return out;
|
||||
};
|
||||
const r = part(), s = part();
|
||||
const raw = new Uint8Array(64);
|
||||
raw.set(r);
|
||||
raw.set(s, 32);
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function challenge(env, kind, extra = {}) {
|
||||
const id = b64u(random(12)), value = b64u(random(32)), now = Date.now();
|
||||
await env.DB.batch([
|
||||
env.DB.prepare("DELETE FROM challenges WHERE expires < ?").bind(now),
|
||||
env.DB.prepare("INSERT INTO challenges (id, challenge, kind, user_id, name, expires) VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.bind(id, value, kind, extra.user ?? null, extra.name ?? null, now + CHALLENGE_MS),
|
||||
]);
|
||||
return { id, value };
|
||||
}
|
||||
|
||||
// One use only: the row is gone whether or not the answer checks out.
|
||||
async function takeChallenge(env, id, kind) {
|
||||
const row = await env.DB.prepare("DELETE FROM challenges WHERE id = ? RETURNING challenge, kind, user_id, name, expires")
|
||||
.bind(String(id || "")).first();
|
||||
if (!row || row.kind !== kind || row.expires < Date.now()) throw new AuthError("that sign-in attempt expired; try again");
|
||||
return row;
|
||||
}
|
||||
|
||||
class AuthError extends Error {}
|
||||
|
||||
// What the browser and the authenticator both vouched for: the ceremony, our challenge, our origin, our host.
|
||||
async function checkCeremony(url, type, expected, clientDataB64, authDataB64) {
|
||||
const clientRaw = unb64u(clientDataB64);
|
||||
const client = JSON.parse(new TextDecoder().decode(clientRaw));
|
||||
if (client.type !== type) throw new AuthError("wrong ceremony");
|
||||
if (client.challenge !== expected) throw new AuthError("challenge mismatch");
|
||||
if (client.origin !== url.origin) throw new AuthError("wrong origin");
|
||||
const auth = unb64u(authDataB64);
|
||||
if (auth.length < 37 || !same(auth.slice(0, 32), await sha256(url.hostname))) throw new AuthError("wrong relying party");
|
||||
const flags = auth[32];
|
||||
if (!(flags & 0x01)) throw new AuthError("no user presence");
|
||||
const counter = new DataView(auth.buffer, auth.byteOffset + 33, 4).getUint32(0);
|
||||
return { clientRaw, auth, flags, counter };
|
||||
}
|
||||
|
||||
async function startSession(env, url, userId, h) {
|
||||
const token = b64u(random(32));
|
||||
const expires = Date.now() + SESSION_DAYS * 86400e3;
|
||||
await env.DB.prepare("INSERT INTO sessions (hash, user_id, expires) VALUES (?, ?, ?)").bind(b64u(await sha256(token)), userId, expires).run();
|
||||
const user = await env.DB.prepare("SELECT id, name FROM users WHERE id = ?").bind(userId).first();
|
||||
return json({ user }, 200, { ...h, "set-cookie": `${COOKIE}=${token}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=${SESSION_DAYS * 86400}` });
|
||||
}
|
||||
|
||||
function cookieToken(req) {
|
||||
const m = (req.headers.get("cookie") || "").match(new RegExp(`(?:^|;\\s*)${COOKIE}=([A-Za-z0-9_-]+)`));
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
async function currentUser(req, env) {
|
||||
const token = cookieToken(req);
|
||||
if (!token) return null;
|
||||
return env.DB.prepare("SELECT users.id, users.name FROM sessions JOIN users ON users.id = sessions.user_id " +
|
||||
"WHERE sessions.hash = ? AND sessions.expires > ?").bind(b64u(await sha256(token)), Date.now()).first();
|
||||
}
|
||||
|
||||
async function auth(req, env, url, path, h) {
|
||||
// Accounts belong to this domain: refuse cross-origin calls outright.
|
||||
const origin = req.headers.get("origin");
|
||||
if (origin && origin !== url.origin) return json({ error: "sign in on the venture's own domain" }, 403, h);
|
||||
if (path === "/api/auth/me") {
|
||||
const user = await currentUser(req, env);
|
||||
return user ? json({ user }, 200, h) : json({ error: "not signed in" }, 401, h);
|
||||
}
|
||||
if (path === "/api/auth/logout" && req.method === "POST") {
|
||||
const token = cookieToken(req);
|
||||
if (token) await env.DB.prepare("DELETE FROM sessions WHERE hash = ?").bind(b64u(await sha256(token))).run();
|
||||
return json({ ok: true }, 200, { ...h, "set-cookie": `${COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0` });
|
||||
}
|
||||
if (req.method !== "POST") return json({ error: "no such endpoint" }, 404, h);
|
||||
let body;
|
||||
try { body = await req.json(); } catch { return json({ error: "send JSON" }, 400, h); }
|
||||
try {
|
||||
if (path === "/api/auth/register/start") {
|
||||
if (!(await spend(env, "register", await who(req, env), 20, 2000))) return json({ error: "over today's limit" }, 429, h);
|
||||
const name = String(body.name || "").trim().slice(0, 40) || "Me";
|
||||
const user = b64u(random(16));
|
||||
const c = await challenge(env, "register", { user, name });
|
||||
return json({ challengeId: c.id, publicKey: {
|
||||
challenge: c.value, rp: { name: env.SLUG, id: url.hostname }, user: { id: user, name, displayName: name },
|
||||
pubKeyCredParams: [{ type: "public-key", alg: -7 }, { type: "public-key", alg: -257 }],
|
||||
authenticatorSelection: { residentKey: "required", userVerification: "preferred" }, attestation: "none", timeout: 60000,
|
||||
} }, 200, h);
|
||||
}
|
||||
if (path === "/api/auth/register/finish") {
|
||||
const c = await takeChallenge(env, body.challengeId, "register");
|
||||
const { flags, counter } = await checkCeremony(url, "webauthn.create", c.challenge, body.clientDataJSON, body.authenticatorData);
|
||||
if (!(flags & 0x40)) throw new AuthError("no credential in the answer");
|
||||
const alg = ALGS[String(body.alg)];
|
||||
if (!alg || !body.publicKey || !/^[A-Za-z0-9_-]{16,1400}$/.test(String(body.id))) throw new AuthError("unsupported passkey");
|
||||
await crypto.subtle.importKey("spki", unb64u(body.publicKey), alg.importAs, false, ["verify"]); // throws if it isn't a key
|
||||
const now = new Date().toISOString();
|
||||
try {
|
||||
await env.DB.batch([
|
||||
env.DB.prepare("INSERT INTO users (id, name, created) VALUES (?, ?, ?)").bind(c.user_id, c.name, now),
|
||||
env.DB.prepare("INSERT INTO creds (id, user_id, spki, alg, counter, created) VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.bind(body.id, c.user_id, body.publicKey, Number(body.alg), counter, now),
|
||||
]);
|
||||
} catch { throw new AuthError("that passkey is already registered here"); }
|
||||
return startSession(env, url, c.user_id, h);
|
||||
}
|
||||
if (path === "/api/auth/login/start") {
|
||||
if (!(await spend(env, "login", await who(req, env), 60, 5000))) return json({ error: "over today's limit" }, 429, h);
|
||||
const c = await challenge(env, "login");
|
||||
return json({ challengeId: c.id, publicKey: { challenge: c.value, rpId: url.hostname, userVerification: "preferred",
|
||||
timeout: 60000 } }, 200, h);
|
||||
}
|
||||
if (path === "/api/auth/login/finish") {
|
||||
const c = await takeChallenge(env, body.challengeId, "login");
|
||||
const { clientRaw, auth: authData, counter } = await checkCeremony(url, "webauthn.get", c.challenge, body.clientDataJSON,
|
||||
body.authenticatorData);
|
||||
const cred = await env.DB.prepare("SELECT user_id, spki, alg, counter FROM creds WHERE id = ?").bind(String(body.id || "")).first();
|
||||
if (!cred) throw new AuthError("unknown passkey for this site");
|
||||
const alg = ALGS[String(cred.alg)];
|
||||
const key = await crypto.subtle.importKey("spki", unb64u(cred.spki), alg.importAs, false, ["verify"]);
|
||||
const signed = new Uint8Array(authData.length + 32);
|
||||
signed.set(authData);
|
||||
signed.set(await sha256(clientRaw), authData.length);
|
||||
let sig = unb64u(body.signature);
|
||||
if (alg.der) sig = derToRaw(sig);
|
||||
if (!(await crypto.subtle.verify(alg.verifyAs, key, sig, signed))) throw new AuthError("signature does not check out");
|
||||
if ((counter || cred.counter) && counter <= cred.counter) throw new AuthError("this passkey looks cloned");
|
||||
await env.DB.prepare("UPDATE creds SET counter = ? WHERE id = ?").bind(counter, body.id).run();
|
||||
return startSession(env, url, cred.user_id, h);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return json({ error: e.message }, 400, h);
|
||||
throw e;
|
||||
}
|
||||
return json({ error: "no such endpoint" }, 404, h);
|
||||
}
|
||||
|
||||
async function api(req, env, url, h) {
|
||||
const path = url.pathname.replace(/\/+$/, "");
|
||||
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: h });
|
||||
if (path === "/api/health") return json({ ok: true, slug: env.SLUG, rev: env.REV, ai: !!env.AI }, 200, h);
|
||||
await setup(env);
|
||||
if (path.startsWith("/api/auth/")) return auth(req, env, url, path, h);
|
||||
if (path === "/api/me/items") {
|
||||
const user = await currentUser(req, env);
|
||||
if (!user) return json({ error: "not signed in" }, 401, h);
|
||||
const rows = await env.DB.prepare("SELECT item_id, created FROM owned WHERE user_id = ? ORDER BY created DESC LIMIT 50")
|
||||
.bind(user.id).all();
|
||||
return json({ items: rows.results.map((r) => ({ id: r.item_id, link: `${url.origin}/?s=${r.item_id}`, created: r.created })) }, 200, h);
|
||||
}
|
||||
if (path === "/api/items" && req.method === "POST") {
|
||||
const text = await req.text();
|
||||
if (text.length > MAX_ITEM) return json({ error: "too big (64 KB max)" }, 413, h);
|
||||
try { JSON.parse(text); } catch { return json({ error: "send JSON" }, 400, h); }
|
||||
if (!(await spend(env, "save", await who(req, env), 50, Number(env.SAVE_DAILY || 2000))))
|
||||
return json({ error: "over today's limit" }, 429, h);
|
||||
const id = crypto.randomUUID().replace(/-/g, "").slice(0, 12);
|
||||
const now = new Date().toISOString();
|
||||
const user = await currentUser(req, env);
|
||||
await env.DB.batch([env.DB.prepare("INSERT INTO items (id, data, created) VALUES (?, ?, ?)").bind(id, text, now),
|
||||
...(user ? [env.DB.prepare("INSERT INTO owned (item_id, user_id, created) VALUES (?, ?, ?)").bind(id, user.id, now)] : [])]);
|
||||
return json({ id, link: `${url.origin}/?s=${id}` }, 201, h);
|
||||
}
|
||||
const item = path.match(/^\/api\/items\/([a-z0-9]{6,32})$/);
|
||||
if (item && req.method === "GET") {
|
||||
const row = await env.DB.prepare("SELECT data FROM items WHERE id = ?").bind(item[1]).first();
|
||||
return row ? new Response(row.data, { headers: { "content-type": "application/json; charset=utf-8", ...h } })
|
||||
: json({ error: "not found" }, 404, h);
|
||||
}
|
||||
if (path === "/api/ai" && req.method === "POST") {
|
||||
if (!env.AI) return json({ error: "no AI on this venture" }, 501, h);
|
||||
let body;
|
||||
try { body = await req.json(); } catch { return json({ error: "send JSON" }, 400, h); }
|
||||
const prompt = String(body.prompt || "").slice(0, MAX_PROMPT);
|
||||
if (!prompt) return json({ error: "prompt is empty" }, 400, h);
|
||||
if (!(await spend(env, "ai", await who(req, env), 20, Number(env.AI_DAILY || 200))))
|
||||
return json({ error: "over today's limit" }, 429, h);
|
||||
const messages = [...(body.system ? [{ role: "system", content: String(body.system).slice(0, MAX_PROMPT) }] : []),
|
||||
{ role: "user", content: prompt }];
|
||||
const out = await env.AI.run(env.AI_MODEL, { messages, max_tokens: 512 });
|
||||
// older models answer { response }, newer ones the OpenAI shape { choices: [{ message: { content } }] }
|
||||
const text = out.response ?? out.choices?.[0]?.message?.content ?? "";
|
||||
return json({ text: typeof text === "string" ? text : JSON.stringify(text) }, 200, h);
|
||||
}
|
||||
return json({ error: "no such endpoint" }, 404, h);
|
||||
}
|
||||
|
||||
// The page itself: the venture's folder on GitHub Pages, under this domain. Pages redirects folder URLs without a
|
||||
// trailing slash to its own host; those are rewritten to stay here.
|
||||
async function page(req, env, url) {
|
||||
const origin = env.ORIGIN.replace(/\/+$/, "");
|
||||
const up = await fetch(origin + url.pathname + url.search, { redirect: "manual", cf: { cacheTtl: 60, cacheEverything: true } });
|
||||
const headers = new Headers(up.headers);
|
||||
const loc = headers.get("location");
|
||||
if (loc && loc.startsWith(origin)) headers.set("location", loc.slice(origin.length) || "/");
|
||||
headers.set("x-time-venture", env.SLUG);
|
||||
return new Response(up.body, { status: up.status, headers });
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(req, env) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === "/api" || url.pathname.startsWith("/api/")) {
|
||||
const h = cors(req, env);
|
||||
try { return await api(req, env, url, h); }
|
||||
catch (e) { return json({ error: "backend error", detail: String(e).slice(0, 200) }, 500, h); }
|
||||
}
|
||||
return page(req, env, url);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,235 @@
|
||||
// AICharts: natural-language charts for the Chart.js you already have, without sending your rows to a model.
|
||||
//
|
||||
// The model sees a dataset's schema, a few sample rows and aggregates, never the data itself. It answers with a small
|
||||
// JSON chart spec. The spec is validated, then a fixed set of operations (filter, group, sort, bin, limit) computes the
|
||||
// chart's data in the browser. No model-written code runs anywhere.
|
||||
//
|
||||
// attach(chart, { data, schema, endpoint }) -> { ask(prompt) } drive an existing Chart.js chart
|
||||
// <ai-chart endpoint="..." dataset="..."> a framework-free element: prompt box, chart, save
|
||||
// validateSpec(spec, schema) -> { ok, errors, fields }
|
||||
// applySpec(rows, spec) -> rows the fixed operations
|
||||
// chartConfig(rows, spec) -> a Chart.js config
|
||||
//
|
||||
// A spec:
|
||||
// { "title": "...", "mark": "bar" | "line" | "pie" | "doughnut" | "scatter",
|
||||
// "transform": [ { "op": "filter", "field": "region", "in": ["West", "East"] },
|
||||
// { "op": "group", "by": ["month"], "agg": "sum", "field": "amount", "as": "value" },
|
||||
// { "op": "sort", "field": "month", "order": "asc" }, { "op": "limit", "n": 12 } ],
|
||||
// "x": "month", "y": "value", "series": "region" (optional) }
|
||||
|
||||
export const MARKS = ["bar", "line", "pie", "doughnut", "scatter"];
|
||||
export const AGGS = ["sum", "avg", "count", "min", "max"];
|
||||
export const OPS = ["filter", "group", "sort", "bin", "limit"];
|
||||
const MAX_STEPS = 8;
|
||||
const NAME = /^[A-Za-z_][A-Za-z0-9_]{0,40}$/;
|
||||
|
||||
// schema: { columns: [{ name, type: "string" | "number" | "month" | "date" }] }
|
||||
export function validateSpec(spec, schema) {
|
||||
const errors = [];
|
||||
const err = (m) => { errors.push(m); return { ok: false, errors }; };
|
||||
if (!spec || typeof spec !== "object" || Array.isArray(spec)) return err("the spec must be a JSON object");
|
||||
if (!MARKS.includes(spec.mark)) err(`mark must be one of ${MARKS.join(", ")}`);
|
||||
const types = new Map((schema?.columns || []).map((c) => [c.name, c.type]));
|
||||
const steps = spec.transform ?? [];
|
||||
if (!Array.isArray(steps)) return err("transform must be a list of steps");
|
||||
if (steps.length > MAX_STEPS) err(`at most ${MAX_STEPS} transform steps`);
|
||||
const has = (f) => types.has(f);
|
||||
steps.forEach((s, i) => {
|
||||
const at = `step ${i + 1}`;
|
||||
if (!s || typeof s !== "object" || !OPS.includes(s.op)) { err(`${at}: op must be one of ${OPS.join(", ")}`); return; }
|
||||
if (s.op === "filter") {
|
||||
if (!has(s.field)) { err(`${at}: unknown field ${JSON.stringify(s.field)}`); return; }
|
||||
const keys = ["eq", "in", "gte", "lte"].filter((k) => k in s);
|
||||
if (!keys.length) err(`${at}: a filter needs eq, in, gte or lte`);
|
||||
if ("in" in s && (!Array.isArray(s.in) || s.in.length > 50)) err(`${at}: in must be a list of at most 50 values`);
|
||||
} else if (s.op === "group") {
|
||||
const by = s.by ?? [];
|
||||
if (!Array.isArray(by) || by.length > 2 || !by.every(has)) { err(`${at}: by must list up to 2 known fields`); return; }
|
||||
if (!AGGS.includes(s.agg)) { err(`${at}: agg must be one of ${AGGS.join(", ")}`); return; }
|
||||
if (s.agg !== "count" && types.get(s.field) !== "number") { err(`${at}: ${s.agg} needs a number field`); return; }
|
||||
const as = s.as ?? "value";
|
||||
if (!NAME.test(as)) { err(`${at}: bad output name ${JSON.stringify(as)}`); return; }
|
||||
const next = new Map(by.map((f) => [f, types.get(f)]));
|
||||
next.set(as, "number");
|
||||
types.clear();
|
||||
next.forEach((t, f) => types.set(f, t));
|
||||
} else if (s.op === "sort") {
|
||||
if (!has(s.field)) err(`${at}: unknown field ${JSON.stringify(s.field)}`);
|
||||
if (s.order && !["asc", "desc"].includes(s.order)) err(`${at}: order must be asc or desc`);
|
||||
} else if (s.op === "bin") {
|
||||
if (types.get(s.field) !== "number") { err(`${at}: bin needs a number field`); return; }
|
||||
if (!(Number(s.step) > 0)) err(`${at}: step must be a positive number`);
|
||||
const as = s.as ?? `${s.field}_bin`;
|
||||
if (!NAME.test(as)) { err(`${at}: bad output name`); return; }
|
||||
types.set(as, "number");
|
||||
} else if (s.op === "limit") {
|
||||
if (!Number.isInteger(s.n) || s.n < 1 || s.n > 500) err(`${at}: n must be a whole number from 1 to 500`);
|
||||
}
|
||||
});
|
||||
for (const k of ["x", "y"]) if (!has(spec[k])) err(`${k} must be a field the transform leaves (got ${JSON.stringify(spec[k])})`);
|
||||
if (has(spec.y) && types.get(spec.y) !== "number") err("y must be a number field");
|
||||
if (spec.series != null && !has(spec.series)) err(`series must be a field the transform leaves`);
|
||||
if (spec.mark === "scatter" && types.get(spec.x) !== "number") err("a scatter needs a number on x");
|
||||
if (spec.title != null && typeof spec.title !== "string") err("title must be text");
|
||||
return { ok: errors.length === 0, errors, fields: Object.fromEntries(types) };
|
||||
}
|
||||
|
||||
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
|
||||
export function applySpec(rows, spec) {
|
||||
let out = rows.map((r) => ({ ...r }));
|
||||
for (const s of spec.transform ?? []) {
|
||||
if (s.op === "filter") {
|
||||
out = out.filter((r) => {
|
||||
const v = r[s.field];
|
||||
if ("eq" in s && v != s.eq) return false; // loose on purpose: "2026" matches 2026
|
||||
if ("in" in s && !s.in.some((x) => x == v)) return false;
|
||||
if ("gte" in s && !(v >= s.gte)) return false;
|
||||
if ("lte" in s && !(v <= s.lte)) return false;
|
||||
return true;
|
||||
});
|
||||
} else if (s.op === "group") {
|
||||
const by = s.by ?? [], as = s.as ?? "value", groups = new Map();
|
||||
for (const r of out) {
|
||||
const key = JSON.stringify(by.map((f) => r[f]));
|
||||
if (!groups.has(key)) groups.set(key, { row: Object.fromEntries(by.map((f) => [f, r[f]])), vals: [] });
|
||||
groups.get(key).vals.push(s.agg === "count" ? 1 : Number(r[s.field]));
|
||||
}
|
||||
out = [...groups.values()].map(({ row, vals }) => {
|
||||
const sum = vals.reduce((a, b) => a + b, 0);
|
||||
const v = { sum, count: vals.length, avg: sum / (vals.length || 1), min: Math.min(...vals), max: Math.max(...vals) }[s.agg];
|
||||
return { ...row, [as]: Math.round(v * 100) / 100 };
|
||||
});
|
||||
} else if (s.op === "sort") {
|
||||
const dir = s.order === "desc" ? -1 : 1;
|
||||
out = [...out].sort((a, b) => dir * cmp(a[s.field], b[s.field]));
|
||||
} else if (s.op === "bin") {
|
||||
const as = s.as ?? `${s.field}_bin`, step = Number(s.step);
|
||||
out = out.map((r) => ({ ...r, [as]: Math.floor(Number(r[s.field]) / step) * step }));
|
||||
} else if (s.op === "limit") {
|
||||
out = out.slice(0, s.n);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const PALETTE = ["#2f6fdf", "#e0752d", "#2f9e6e", "#c2417a", "#7a5bd6", "#9a8a2a", "#3aa0b8", "#b8573a"];
|
||||
|
||||
export function chartConfig(rows, spec) {
|
||||
const title = spec.title ? { display: true, text: spec.title } : { display: false };
|
||||
if (spec.mark === "scatter") {
|
||||
return { type: "scatter", data: { datasets: [{ label: spec.y, data: rows.map((r) => ({ x: Number(r[spec.x]), y: Number(r[spec.y]) })),
|
||||
backgroundColor: PALETTE[0] }] }, options: { plugins: { title }, animation: false } };
|
||||
}
|
||||
const labels = [...new Set(rows.map((r) => r[spec.x]))];
|
||||
let datasets;
|
||||
if (spec.series && !["pie", "doughnut"].includes(spec.mark)) {
|
||||
const names = [...new Set(rows.map((r) => r[spec.series]))];
|
||||
datasets = names.map((name, i) => ({
|
||||
label: String(name),
|
||||
data: labels.map((l) => rows.find((r) => r[spec.x] === l && r[spec.series] === name)?.[spec.y] ?? null),
|
||||
backgroundColor: PALETTE[i % PALETTE.length], borderColor: PALETTE[i % PALETTE.length],
|
||||
}));
|
||||
} else {
|
||||
const round = ["pie", "doughnut"].includes(spec.mark);
|
||||
datasets = [{ label: spec.y, data: labels.map((l) => rows.find((r) => r[spec.x] === l)?.[spec.y] ?? null),
|
||||
backgroundColor: round ? labels.map((_, i) => PALETTE[i % PALETTE.length]) : PALETTE[0], borderColor: PALETTE[0] }];
|
||||
}
|
||||
return { type: spec.mark, data: { labels: labels.map(String), datasets },
|
||||
options: { plugins: { title, legend: { display: datasets.length > 1 || ["pie", "doughnut"].includes(spec.mark) } }, animation: false } };
|
||||
}
|
||||
|
||||
async function fetchSpec(endpoint, body) {
|
||||
const r = await fetch(endpoint.replace(/\/$/, "") + "/api/spec", { method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body) });
|
||||
const out = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(out.error || `the chart service answered ${r.status}`);
|
||||
return out; // { spec, brain?, model?, ms?, confidence?, alternatives?, handoff? }
|
||||
}
|
||||
|
||||
// Drive an existing Chart.js chart. `data` stays in the browser; only `schema` (plus a sample and aggregates the server
|
||||
// computes for its own datasets) shapes the prompt.
|
||||
export function attach(chart, { data, schema, endpoint, dataset }) {
|
||||
let last;
|
||||
return {
|
||||
async ask(prompt, prior = last, brain) {
|
||||
const { spec } = await fetchSpec(endpoint, { dataset, prompt, prior, ...(brain ? { brain } : {}) });
|
||||
const v = validateSpec(spec, schema);
|
||||
if (!v.ok) throw new Error("the spec failed validation: " + v.errors.join("; "));
|
||||
const cfg = chartConfig(applySpec(data, spec), spec);
|
||||
chart.config.type = cfg.type;
|
||||
chart.config.data = cfg.data;
|
||||
chart.config.options = { ...chart.config.options, ...cfg.options };
|
||||
chart.update();
|
||||
last = spec;
|
||||
return spec;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const Base = globalThis.HTMLElement ?? class {};
|
||||
|
||||
// <ai-chart endpoint="https://..." dataset="orders" brain="jev"></ai-chart> brain: llm | jev | clef (server default if unset)
|
||||
// Needs Chart.js on the page (window.Chart). Fires "spec" after each answer and "save" when Save is pressed; set
|
||||
// element.onSave = async ({ spec, prompt, dataset }) => ... to store charts yourself (the demo stores them in D1).
|
||||
export class AiChart extends Base {
|
||||
connectedCallback() {
|
||||
if (this._ready) return;
|
||||
this._ready = true;
|
||||
this.innerHTML = `<form class="aic-ask"><input name="q" placeholder="Ask for a chart, e.g. sales by region" aria-label="Chart question"
|
||||
autocomplete="off"><button>Chart it</button></form><div class="aic-msg" role="status"></div>
|
||||
<div class="aic-canvas"><canvas></canvas></div><button class="aic-save" hidden>Save chart</button>`;
|
||||
this._msg = this.querySelector(".aic-msg");
|
||||
this.querySelector("form").addEventListener("submit", (e) => { e.preventDefault(); this.ask(e.target.q.value); });
|
||||
this.querySelector(".aic-save").addEventListener("click", () => this.save());
|
||||
}
|
||||
|
||||
get endpoint() { return this.getAttribute("endpoint") || location.origin; }
|
||||
get dataset() { return this.getAttribute("dataset") || "orders"; }
|
||||
|
||||
async load() {
|
||||
if (this._data) return;
|
||||
const base = this.endpoint.replace(/\/$/, "");
|
||||
const [schema, data] = await Promise.all([fetch(`${base}/api/schema?dataset=${this.dataset}`).then((r) => r.json()),
|
||||
fetch(`${base}/api/data?dataset=${this.dataset}`).then((r) => r.json())]);
|
||||
this._schema = schema.schema;
|
||||
this._data = data.rows;
|
||||
}
|
||||
|
||||
async ask(prompt) {
|
||||
if (!prompt?.trim()) return;
|
||||
this._msg.textContent = "Thinking…";
|
||||
try {
|
||||
await this.load();
|
||||
const brain = this.getAttribute("brain");
|
||||
const { spec, ...info } = await fetchSpec(this.endpoint, { dataset: this.dataset, prompt, prior: this._spec, ...(brain ? { brain } : {}) });
|
||||
await this.render(spec, prompt);
|
||||
this.dispatchEvent(new CustomEvent("spec", { detail: { spec, prompt, info } }));
|
||||
} catch (e) {
|
||||
this._msg.textContent = String(e.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
async render(spec, prompt) {
|
||||
await this.load();
|
||||
const v = validateSpec(spec, this._schema);
|
||||
if (!v.ok) throw new Error("That chart didn't pass the checks: " + v.errors.join("; "));
|
||||
const cfg = chartConfig(applySpec(this._data, spec), spec);
|
||||
this._chart?.destroy();
|
||||
this._chart = new globalThis.Chart(this.querySelector("canvas"), cfg);
|
||||
this._spec = spec;
|
||||
this._prompt = prompt;
|
||||
this._msg.textContent = spec.title || "";
|
||||
this.querySelector(".aic-save").hidden = false;
|
||||
}
|
||||
|
||||
async save() {
|
||||
const detail = { spec: this._spec, prompt: this._prompt, dataset: this.dataset };
|
||||
this.dispatchEvent(new CustomEvent("save", { detail }));
|
||||
if (this.onSave) {
|
||||
try { await this.onSave(detail); this._msg.textContent = "Saved."; } catch (e) { this._msg.textContent = String(e.message || e); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (globalThis.customElements && !customElements.get("ai-chart")) customElements.define("ai-chart", AiChart);
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Compare AICharts brains on one fixed question set against the live demo.
|
||||
|
||||
python3 bench.py https://aicharts.yolopreneur.lol [brain ...] (default brains: llm jev clef)
|
||||
python3 bench.py <url> clef@0 Clef with the fallback off (calibration)
|
||||
|
||||
Each question lists what a right chart must have; a spec scores 1 when it has all of it. Reports, per brain: right
|
||||
answers, median and p90 latency (as the browser sees it), and how often a decision brain handed off to the LLM.
|
||||
Uses the public API like a visitor, so it counts against the demo's daily AI limit (12 calls per brain).
|
||||
"""
|
||||
import json
|
||||
import statistics
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
Q = [
|
||||
("total revenue by region", {"x": "region", "agg": ("sum", "amount")}),
|
||||
("monthly revenue, web vs store", {"mark": {"line"}, "x": "month", "series": "channel", "agg": ("sum", "amount")}),
|
||||
("share of units by product", {"mark": {"pie", "doughnut"}, "x": "product", "agg": ("sum", "units")}),
|
||||
("top 3 products in the West by revenue", {"x": "product", "only": {"region": "West"}, "agg": ("sum", "amount"), "limit": 3}),
|
||||
("average order amount by channel", {"x": "channel", "agg": ("avg", "amount")}),
|
||||
("how did matcha units trend month by month?", {"mark": {"line"}, "x": "month", "only": {"product": "Matcha"}, "agg": ("sum", "units")}),
|
||||
("how many orders per region", {"x": "region", "agg": ("count", None)}),
|
||||
("biggest single order amount for each product", {"x": "product", "agg": ("max", "amount")}),
|
||||
("store revenue by region", {"x": "region", "only": {"channel": "store"}, "agg": ("sum", "amount")}),
|
||||
("teapot units by month in the North", {"x": "month", "only": {"product": "Teapot", "region": "North"}, "agg": ("sum", "units")}),
|
||||
("which region sells the most hojicha?", {"x": "region", "only": {"product": "Hojicha"}, "sorted_desc": True}),
|
||||
("revenue by product, split by channel", {"x": {"product", "channel"}, "series": {"product", "channel"}, "agg": ("sum", "amount")}),
|
||||
]
|
||||
|
||||
|
||||
def facts(spec):
|
||||
f = {"mark": spec.get("mark"), "x": spec.get("x"), "series": spec.get("series"), "only": {}, "limit": None, "sorted_desc": False}
|
||||
for s in spec.get("transform") or []:
|
||||
if s.get("op") == "filter":
|
||||
if "eq" in s:
|
||||
f["only"][s["field"]] = s["eq"]
|
||||
elif isinstance(s.get("in"), list) and len(s["in"]) == 1:
|
||||
f["only"][s["field"]] = s["in"][0]
|
||||
elif s.get("op") == "group":
|
||||
f["agg"] = (s.get("agg"), None if s.get("agg") == "count" else s.get("field"))
|
||||
elif s.get("op") == "limit":
|
||||
f["limit"] = s.get("n")
|
||||
elif s.get("op") == "sort" and s.get("order") == "desc" and s.get("field") not in ("month", "date", f["x"]):
|
||||
f["sorted_desc"] = True
|
||||
return f
|
||||
|
||||
|
||||
def misses(spec, want):
|
||||
f, out = facts(spec), []
|
||||
for k, v in want.items():
|
||||
got = f.get(k)
|
||||
if k == "only":
|
||||
if {a: str(b).lower() for a, b in got.items()} != {a: b.lower() for a, b in v.items()}:
|
||||
out.append(f"only {got} != {v}")
|
||||
elif isinstance(v, set):
|
||||
if got not in v:
|
||||
out.append(f"{k} {got} not in {sorted(v)}")
|
||||
elif k == "agg":
|
||||
if got is None or got[0] != v[0] or (v[1] and got[1] != v[1]):
|
||||
out.append(f"agg {got} != {v}")
|
||||
elif got != v:
|
||||
out.append(f"{k} {got} != {v}")
|
||||
if "x" in want and "series" in want and f["x"] == f["series"]:
|
||||
out.append("x == series")
|
||||
return out
|
||||
|
||||
|
||||
def ask(url, prompt, brain):
|
||||
name, _, floor = brain.partition("@") # "clef@0" = Clef with no fallback: its own answers, for calibration
|
||||
body = json.dumps({"dataset": "orders", "prompt": prompt, "brain": name, **({"floor": float(floor)} if floor else {})}).encode()
|
||||
req = urllib.request.Request(url.rstrip("/") + "/api/spec", data=body, # Cloudflare turns away Python's default agent
|
||||
headers={"content-type": "application/json", "user-agent": "aicharts-bench/1"})
|
||||
t0 = time.time()
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
out = json.loads(r.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
out = json.loads(e.read() or b"{}")
|
||||
return out, int((time.time() - t0) * 1000)
|
||||
|
||||
|
||||
def main():
|
||||
url, brains = sys.argv[1], sys.argv[2:] or ["llm", "jev", "clef"]
|
||||
report = {}
|
||||
for brain in brains:
|
||||
rows = []
|
||||
for prompt, want in Q:
|
||||
out, ms = ask(url, prompt, brain)
|
||||
m = misses(out["spec"], want) if out.get("spec") else [out.get("error", "no spec")]
|
||||
rows.append({"q": prompt, "ok": not m, "ms": ms, "via": out.get("brain"), "handoff": (out.get("handoff") or {}).get("reason"),
|
||||
"confidence": out.get("confidence"), "confidences": out.get("confidences"), "misses": m})
|
||||
print(f"[{brain}] {'ok ' if not m else 'MISS'} {ms:>6} ms via {out.get('brain')} conf {out.get('confidence')}: {prompt}"
|
||||
+ (f" -- {'; '.join(m)}" if m else "")
|
||||
+ (f" (handoff: {rows[-1]['handoff']})" if rows[-1]["handoff"] else ""), flush=True)
|
||||
lat = sorted(r["ms"] for r in rows)
|
||||
report[brain] = {"right": sum(r["ok"] for r in rows), "of": len(rows), "median_ms": int(statistics.median(lat)),
|
||||
"p90_ms": lat[int(0.9 * (len(lat) - 1))], "handoffs": sum(1 for r in rows if r["handoff"]), "rows": rows}
|
||||
print(json.dumps({b: {k: v for k, v in r.items() if k != "rows"} for b, r in report.items()}, indent=1))
|
||||
json.dump(report, open("bench.json", "w"), indent=1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,121 @@
|
||||
// A decision-model brain for AICharts: Jev (TypeSafe) or Clef (Cloudflare) answers a handful of choice questions in
|
||||
// one pass, and this module assembles the chart spec from the answers. The model never writes JSON, so a spec can't
|
||||
// be malformed; the validator still checks it, and weak confidence hands the question to the LLM path.
|
||||
//
|
||||
// questions(info) -> System One questions for one dataset (info = describe(rows, columns))
|
||||
// assemble(answers, info) -> { spec, confidence, weakest, alternatives }
|
||||
// decide(env, model, info, prompt, prior) -> the above plus { model, ms } (throws if the model call fails)
|
||||
|
||||
const MARK_MENU = {
|
||||
bar: "Comparing amounts across categories or a handful of periods.",
|
||||
line: "A value over time (months, dates) or another ordered sequence; the trend matters.",
|
||||
pie: "Shares of one whole across a few categories (6 or fewer).",
|
||||
doughnut: "Shares of one whole, shown as a ring; same use as a pie.",
|
||||
scatter: "Two numeric measures plotted against each other across many points.",
|
||||
};
|
||||
const AGG_MENU = {
|
||||
sum: "The total (revenue, units sold): add the values up.",
|
||||
avg: "The typical or average value per order.",
|
||||
count: "How many orders or rows, regardless of their values.",
|
||||
min: "The smallest value.",
|
||||
max: "The largest value.",
|
||||
};
|
||||
const ORDER_MENU = {
|
||||
natural: "No particular order asked for (time stays in time order).",
|
||||
value_desc: "Biggest first: top, best, most, highest, ranking.",
|
||||
value_asc: "Smallest first: bottom, worst, least, lowest.",
|
||||
};
|
||||
const TOP_MENU = { all: "Show every group.", 3: "Only the top or bottom 3.", 5: "Only the top or bottom 5.", 10: "Only the top or bottom 10." };
|
||||
|
||||
function shuffled(menu) {
|
||||
const e = Object.entries(menu);
|
||||
for (let i = e.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [e[i], e[j]] = [e[j], e[i]]; }
|
||||
return Object.fromEntries(e);
|
||||
}
|
||||
const choice = (instructions, menu) => ({ type: "choice", instructions, criteria: shuffled(menu) });
|
||||
|
||||
const describeCol = (c, agg) => {
|
||||
const a = agg[c.name] || {};
|
||||
if (a.values) return `${c.type} column; values: ${a.values.join(", ")}`;
|
||||
if ("min" in a) return `${c.type} column from ${a.min} to ${a.max}`;
|
||||
return `${c.type} column`;
|
||||
};
|
||||
|
||||
export function questions(info) {
|
||||
const cols = info.columns.filter((c) => c.name !== "id");
|
||||
const text = cols.filter((c) => c.type === "string");
|
||||
const nums = cols.filter((c) => c.type === "number");
|
||||
const axis = Object.fromEntries(cols.map((c) => [c.name, describeCol(c, info.aggregates)]));
|
||||
const measure = { ...Object.fromEntries(nums.map((c) => [c.name, describeCol(c, info.aggregates)])), rows: "The number of orders (no column)." };
|
||||
const q = {
|
||||
mark: choice("Which chart type answers the request best?", MARK_MENU),
|
||||
x: choice("Which column goes on the x axis (the categories or time the chart is broken down by)?", axis),
|
||||
measure: choice("Which quantity does the chart measure?", measure),
|
||||
agg: choice("How should that quantity be combined within each group?", AGG_MENU),
|
||||
series: choice("Should the chart split into one coloured series per value of another column? Pick none unless the request compares groups against each other (\"web vs store\", \"by region over time\").",
|
||||
{ none: "One series only.", ...Object.fromEntries(text.map((c) => [c.name, `one series per ${c.name}`])) }),
|
||||
order: choice("How should the groups be ordered?", ORDER_MENU),
|
||||
top: choice("Does the request limit how many groups to show?", TOP_MENU),
|
||||
};
|
||||
for (const c of text) {
|
||||
const vals = info.aggregates[c.name]?.values;
|
||||
if (vals?.length && vals.length <= 30)
|
||||
q[`only.${c.name}`] = choice(`Does the request restrict the data to one ${c.name}? Pick any unless it names one.`,
|
||||
{ any: `No restriction on ${c.name}.`, ...Object.fromEntries(vals.map((v) => [v, `Only ${c.name} = ${v}.`])) });
|
||||
}
|
||||
return q;
|
||||
}
|
||||
|
||||
export function assemble(answers, info) {
|
||||
const pick = (k) => answers[k]?.choice;
|
||||
const conf = Object.fromEntries(Object.entries(answers).map(([k, v]) => [k, Number(v?.confidence ?? 0)]));
|
||||
const types = Object.fromEntries(info.columns.map((c) => [c.name, c.type]));
|
||||
const mark = pick("mark"), x = pick("x"), measure = pick("measure");
|
||||
let agg = pick("agg");
|
||||
let series = pick("series");
|
||||
if (series === "none" || series === x || ["pie", "doughnut", "scatter"].includes(mark)) series = undefined;
|
||||
const gating = ["mark", "x", "measure", "order", "top"];
|
||||
if (measure === "rows") agg = "count"; else gating.push("agg");
|
||||
if (series) gating.push("series");
|
||||
|
||||
const transform = [];
|
||||
for (const k of Object.keys(answers).filter((k) => k.startsWith("only."))) {
|
||||
const v = pick(k);
|
||||
if (v && v !== "any") { transform.push({ op: "filter", field: k.slice(5), eq: v }); gating.push(k); }
|
||||
}
|
||||
let spec;
|
||||
if (mark === "scatter") {
|
||||
// a scatter plots rows, not groups: x and the measure are both raw number columns
|
||||
spec = { mark, x, y: measure === "rows" ? "amount" : measure, transform };
|
||||
} else {
|
||||
transform.push({ op: "group", by: series ? [x, series] : [x], agg, ...(agg === "count" ? {} : { field: measure }), as: "value" });
|
||||
const order = pick("order");
|
||||
if (order === "value_desc" || order === "value_asc") transform.push({ op: "sort", field: "value", order: order === "value_desc" ? "desc" : "asc" });
|
||||
else if (["month", "date", "number"].includes(types[x])) transform.push({ op: "sort", field: x, order: "asc" });
|
||||
const top = pick("top");
|
||||
if (top && top !== "all") transform.push({ op: "limit", n: Number(top) });
|
||||
spec = { mark, x, y: "value", ...(series ? { series } : {}), transform };
|
||||
}
|
||||
const what = measure === "rows" ? "Orders" : `${{ sum: "Total", avg: "Average", count: "Count of", min: "Lowest", max: "Highest" }[agg]} ${measure}`;
|
||||
const only = transform.filter((s) => s.op === "filter").map((s) => s.eq);
|
||||
spec.title = mark === "scatter" ? `${spec.y} vs ${x}` : `${what} by ${x}${series ? ` and ${series}` : ""}${only.length ? ` (${only.join(", ")})` : ""}`;
|
||||
|
||||
const weakestOn = gating.reduce((lo, k) => ((conf[k] ?? 0) < (conf[lo] ?? 0) ? k : lo), gating[0]);
|
||||
const weakest = conf[weakestOn] ?? 0;
|
||||
// other chart types the model rated close behind: cheap to offer, already the same data
|
||||
const probs = answers.mark?.probabilities || {};
|
||||
const alternatives = Object.entries(probs).filter(([m, p]) => m !== mark && p >= 0.15).sort((a, b) => b[1] - a[1]).slice(0, 2)
|
||||
.map(([m, p]) => ({ mark: m, p: Math.round(p * 100) / 100 }));
|
||||
return { spec, confidence: conf, weakest: Math.round(weakest * 100) / 100, weakestOn, alternatives };
|
||||
}
|
||||
|
||||
export async function decide(env, model, info, prompt, prior) {
|
||||
const state = { user_request: prompt, table: { columns: info.columns.map((c) => c.name), rows: info.rows },
|
||||
...(prior ? { current_chart: prior } : {}) };
|
||||
const input = { state, questions: questions(info) };
|
||||
if (model.startsWith("@cf/cloudflare/clef")) input.model = model.endsWith("flash") ? "clef-flash" : "clef";
|
||||
const t0 = Date.now();
|
||||
const res = await env.AI.run(model, input);
|
||||
if (!res?.answers) throw new Error("the decision model returned no answers");
|
||||
return { ...assemble(res.answers, info), model: res.model || model, ms: Date.now() - t0 };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Deploy the AICharts demo: one Worker (`time-aicharts`), one D1 database, Workers AI.
|
||||
|
||||
python3 .github/time/labs/aicharts/deploy.py # prints {url, worker, db, rev}
|
||||
|
||||
Reuses cloudflare.py. Lives where ventures live: aicharts.<TIME_VENTURE_DOMAIN> when that is set (a Workers custom
|
||||
domain; Cloudflare adds the record and the cert), the account's workers.dev otherwise. Idempotent: the same database is
|
||||
reused and the script is replaced in place.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE.parents[1]))
|
||||
import cloudflare as cf # noqa: E402
|
||||
|
||||
NAME = "time-aicharts"
|
||||
MAIN = b'import src from "./aicharts.txt";\nimport { make } from "./worker.js";\nexport default make(src);\n'
|
||||
|
||||
|
||||
def parts() -> list[tuple[str, str, bytes]]:
|
||||
lib = (HERE / "aicharts.js").read_bytes()
|
||||
return [("main.js", "application/javascript+module", MAIN),
|
||||
("worker.js", "application/javascript+module", (HERE / "worker.js").read_bytes()),
|
||||
("aicharts.js", "application/javascript+module", lib),
|
||||
("decide.js", "application/javascript+module", (HERE / "decide.js").read_bytes()),
|
||||
("aicharts.txt", "text/plain", lib)]
|
||||
|
||||
|
||||
def deploy() -> dict:
|
||||
if not cf.enabled():
|
||||
sys.exit("CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are needed")
|
||||
code = parts()
|
||||
rev = hashlib.sha256(b"".join(d for _, _, d in code)).hexdigest()[:12]
|
||||
vars_ = {"REV": rev, "AI_MODEL": cf.env("AICHARTS_AI_MODEL", "@cf/meta/llama-3.3-70b-instruct-fp8-fast"), "AI_DAILY": cf.env("AICHARTS_AI_DAILY", "300"),
|
||||
"SAVE_DAILY": cf.env("AICHARTS_SAVE_DAILY", "1000"),
|
||||
"BRAIN": cf.env("AICHARTS_BRAIN", "llm"), "FLOOR": cf.env("AICHARTS_FLOOR", "0.5")}
|
||||
db = cf.database("aicharts") # named time-aicharts, like the script
|
||||
meta = {"main_module": "main.js", "compatibility_date": cf.COMPAT,
|
||||
"bindings": [{"type": "d1", "name": "DB", "id": db}, {"type": "ai", "name": "AI"}]
|
||||
+ [{"type": "plain_text", "name": k, "text": str(v)} for k, v in vars_.items()]}
|
||||
data, ctype = cf.multipart([("metadata", "application/json", json.dumps(meta).encode()), *code])
|
||||
cf.cf("PUT", f"{cf.account()}/workers/scripts/{NAME}", data=data, ctype=ctype)
|
||||
hostname = cf.host("aicharts")
|
||||
return {"url": cf.attach(NAME, hostname) if hostname else cf.workers_dev(NAME), "worker": NAME, "db": db, "rev": rev}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(json.dumps(deploy(), indent=1))
|
||||
@@ -0,0 +1,250 @@
|
||||
// Offline test for the AICharts library and demo Worker: a real SQLite behind a D1-shaped shim, and a fake Workers AI.
|
||||
// Run: node .github/time/labs/aicharts/test_aicharts.mjs
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const lib = await import(join(here, "aicharts.js"));
|
||||
const W = await import(join(here, "worker.js"));
|
||||
const src = readFileSync(join(here, "aicharts.js"), "utf8");
|
||||
|
||||
// ---------- library ----------
|
||||
const schema = { columns: [{ name: "month", type: "month" }, { name: "region", type: "string" }, { name: "amount", type: "number" }] };
|
||||
const rows = [
|
||||
{ month: "2026-01", region: "West", amount: 10 }, { month: "2026-01", region: "East", amount: 5 },
|
||||
{ month: "2026-02", region: "West", amount: 7 }, { month: "2026-02", region: "East", amount: 9 },
|
||||
];
|
||||
const bySum = { mark: "bar", x: "region", y: "value",
|
||||
transform: [{ op: "group", by: ["region"], agg: "sum", field: "amount", as: "value" }, { op: "sort", field: "value", order: "desc" }] };
|
||||
assert.ok(lib.validateSpec(bySum, schema).ok);
|
||||
assert.deepEqual(lib.applySpec(rows, bySum), [{ region: "West", value: 17 }, { region: "East", value: 14 }]);
|
||||
|
||||
const bad = lib.validateSpec({ mark: "radar", x: "month", y: "amount",
|
||||
transform: [{ op: "group", by: ["region"], agg: "sum", field: "amount" }, { op: "eval", code: "alert(1)" }] }, schema);
|
||||
assert.equal(bad.ok, false);
|
||||
assert.ok(bad.errors.some((e) => e.includes("mark")));
|
||||
assert.ok(bad.errors.some((e) => e.includes("step 2")));
|
||||
assert.ok(bad.errors.some((e) => e.startsWith("x must be a field the transform leaves")), "month is gone after the group");
|
||||
assert.equal(lib.validateSpec({ mark: "bar", x: "region", y: "region" }, schema).ok, false, "y must be a number");
|
||||
assert.equal(lib.validateSpec({ mark: "bar", x: "region", y: "amount", transform: Array(9).fill({ op: "limit", n: 1 }) }, schema).ok, false);
|
||||
assert.equal(lib.validateSpec("[1]", schema).ok, false);
|
||||
|
||||
const lines = { mark: "line", x: "month", y: "value", series: "region",
|
||||
transform: [{ op: "group", by: ["month", "region"], agg: "sum", field: "amount", as: "value" }, { op: "sort", field: "month" }] };
|
||||
const cfg = lib.chartConfig(lib.applySpec(rows, lines), lines);
|
||||
assert.deepEqual(cfg.data.labels, ["2026-01", "2026-02"]);
|
||||
assert.deepEqual(cfg.data.datasets.map((d) => [d.label, d.data]), [["West", [10, 7]], ["East", [5, 9]]]);
|
||||
const pie = lib.chartConfig(lib.applySpec(rows, { ...bySum, mark: "pie" }), { ...bySum, mark: "pie" });
|
||||
assert.equal(pie.data.datasets[0].backgroundColor.length, 2);
|
||||
const binned = lib.applySpec(rows, { transform: [{ op: "bin", field: "amount", step: 5 }, { op: "filter", field: "amount_bin", gte: 5, lte: 5 }] });
|
||||
assert.deepEqual(binned.map((r) => r.amount), [5, 7, 9]);
|
||||
|
||||
// attach() drives an existing chart and keeps the rows in the browser
|
||||
const sent = [];
|
||||
globalThis.fetch = async (url, init) => { sent.push({ url, body: JSON.parse(init.body) }); return Response.json({ spec: bySum }); };
|
||||
const chart = { config: { type: "line", data: {}, options: { responsive: true } }, updates: 0, update() { this.updates++; } };
|
||||
await lib.attach(chart, { data: rows, schema, endpoint: "https://x.example/", dataset: "orders" }).ask("sales by region");
|
||||
assert.equal(sent[0].url, "https://x.example/api/spec");
|
||||
assert.deepEqual(Object.keys(sent[0].body).sort(), ["dataset", "prompt"], "no rows go to the server");
|
||||
assert.equal(chart.config.type, "bar");
|
||||
assert.equal(chart.config.options.responsive, true);
|
||||
assert.equal(chart.updates, 1);
|
||||
await lib.attach(chart, { data: rows, schema, endpoint: "https://x.example", dataset: "orders" }).ask("now as a pie");
|
||||
const a = lib.attach(chart, { data: rows, schema, endpoint: "https://x.example", dataset: "orders" });
|
||||
await a.ask("one"); await a.ask("two");
|
||||
assert.deepEqual(sent.at(-1).body.prior, bySum, "follow-ups carry the last chart");
|
||||
|
||||
// ---------- worker ----------
|
||||
assert.deepEqual(W.firstObject('Sure!\n```json\n{"a": {"b": "}"}}\n```'), { a: { b: "}" } });
|
||||
assert.deepEqual(W.firstObject('Sure! {"mark": "bar", "x": "a"} trailing {'), { mark: "bar", x: "a" });
|
||||
assert.equal(W.firstObject("no json"), null);
|
||||
assert.deepEqual(W.normalizeSpec({ mark: "Bar", x: "region", y: "value",
|
||||
transform: [{ type: "groupBy", groupBy: "region", aggregate: "SUM", field: "amount", as: "value" }, { op: "sort", field: "value", order: "DESC" }] }),
|
||||
{ mark: "bar", x: "region", y: "value", transform: [{ op: "group", by: ["region"], agg: "sum", field: "amount", as: "value" }, { op: "sort", field: "value", order: "desc" }] });
|
||||
assert.equal(W.normalizeSpec({ mark: "bar", transform: [{ op: "group", agg: "mean" }] }).transform[0].agg, "avg");
|
||||
|
||||
class D1 {
|
||||
constructor() { this.db = new DatabaseSync(":memory:"); this.writes = []; }
|
||||
prepare(sql) {
|
||||
const db = this;
|
||||
const make = (args) => ({
|
||||
bind: (...a) => make(a),
|
||||
async all() { db.log(sql); return { results: db.db.prepare(sql).all(...args) }; },
|
||||
async first() { db.log(sql); return db.db.prepare(sql).get(...args) ?? null; },
|
||||
async run() { db.log(sql); const s = db.db.prepare(sql); return /RETURNING|^\s*SELECT/i.test(sql) ? { results: s.all(...args) } : (s.run(...args), { results: [] }); },
|
||||
});
|
||||
return make([]);
|
||||
}
|
||||
log(sql) { if (/^\s*(INSERT|UPDATE|DELETE|CREATE)/i.test(sql)) this.writes.push(sql.split("(")[0].trim()); }
|
||||
async batch(stmts) { return Promise.all(stmts.map((s) => s.run())); }
|
||||
}
|
||||
|
||||
const answers = [];
|
||||
const calls = [];
|
||||
const env = { DB: new D1(), REV: "r1", AI_MODEL: "@cf/test/m", AI_DAILY: "5", SAVE_DAILY: "100",
|
||||
AI: { async run(model, input) {
|
||||
if (input.questions) { decisions.push({ model, input }); const d = decided.shift(); if (d instanceof Error) throw d; return d; }
|
||||
calls.push(input); const a = answers.shift(); if (a instanceof Error) throw a; return a; } } };
|
||||
const decisions = [], decided = [];
|
||||
const worker = W.make(src);
|
||||
const call = (path, init = {}, ip = "1.1.1.1") =>
|
||||
worker.fetch(new Request("https://aicharts.t.workers.dev" + path, { ...init, headers: { "cf-connecting-ip": ip, ...(init.headers || {}) } }), env);
|
||||
const post = (path, body, ip) => call(path, { method: "POST", body: JSON.stringify(body), headers: { "content-type": "application/json" } }, ip);
|
||||
|
||||
let r = await call("/");
|
||||
let html = await r.text();
|
||||
assert.ok(html.includes("<ai-chart") && html.includes("https://aicharts.t.workers.dev/aicharts.js"));
|
||||
r = await call("/aicharts.js");
|
||||
assert.equal(await r.text(), src);
|
||||
assert.equal(r.headers.get("access-control-allow-origin"), "*");
|
||||
|
||||
// the schema: what the model sees. Seeded once, deterministic.
|
||||
r = await call("/api/schema?dataset=orders");
|
||||
const sch = await r.json();
|
||||
assert.equal(sch.sample.length, 5);
|
||||
assert.deepEqual(sch.aggregates.region.values, ["East", "North", "South", "West"]);
|
||||
assert.equal(sch.schema.columns.find((c) => c.name === "amount").type, "number");
|
||||
const data = (await (await call("/api/data?dataset=orders")).json()).rows;
|
||||
assert.equal(data.length, 24 * 4 * 5);
|
||||
assert.deepEqual(data, W.seedOrders());
|
||||
assert.equal((await call("/api/schema?dataset=users")).status, 404);
|
||||
const seeded = env.DB.writes.filter((w) => w.startsWith("INSERT OR IGNORE INTO orders")).length;
|
||||
await call("/api/data");
|
||||
assert.equal(env.DB.writes.filter((w) => w.startsWith("INSERT OR IGNORE INTO orders")).length, seeded, "seeding happens once");
|
||||
|
||||
// a spec: plain prompt; the system prompt carries schema + sample, never all rows
|
||||
const good = { title: "Revenue by region", mark: "bar", x: "region", y: "value",
|
||||
transform: [{ op: "group", by: ["region"], agg: "sum", field: "amount", as: "value" }] };
|
||||
answers.push({ response: good });
|
||||
r = await post("/api/spec", { dataset: "orders", prompt: "revenue by region" });
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual((await r.json()).spec, good);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].response_format, undefined, "plain prompt first");
|
||||
const sys = calls[0].messages[0].content;
|
||||
assert.ok(sys.includes("region (string)") && sys.includes('"North"'));
|
||||
assert.ok(sys.length < 4000 && !sys.includes(`"id":${data.length}`), "the prompt has a sample, not the table");
|
||||
|
||||
// no JSON at all -> JSON mode; prose around JSON is fine; a bad answer gets one repair round with the errors
|
||||
answers.push({ response: "I think a line chart would be nice." },
|
||||
{ response: '{"mark": "bar", "x": "month", "y": "amount", "transform": [{"op": "group", "by": ["region"], "agg": "sum", "field": "amount"}]}' },
|
||||
{ choices: [{ message: { content: "Fixed: " + JSON.stringify({ ...good, mark: "line", x: "region" }) } }] });
|
||||
calls.length = 0;
|
||||
r = await post("/api/spec", { dataset: "orders", prompt: "trend", prior: good });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal((await r.json()).spec.mark, "line");
|
||||
assert.deepEqual(calls.map((c) => c.response_format?.type ?? null), [null, "json_object", null]);
|
||||
assert.ok(calls[0].messages.some((m) => m.content.includes("Current chart spec")), "the prior chart is context");
|
||||
assert.ok(calls.at(-1).messages.at(-1).content.includes("fails these checks"), "one repair round with the errors");
|
||||
|
||||
// two bad answers: a 422 with the reasons, nothing drawn
|
||||
answers.push({ response: "{}" }, { response: '{"mark": "pie"}' });
|
||||
r = await post("/api/spec", { dataset: "orders", prompt: "nonsense" });
|
||||
assert.equal(r.status, 422);
|
||||
const failed = await r.json();
|
||||
assert.match(failed.error, /valid chart/);
|
||||
assert.equal(failed.model_said, '{"mark": "pie"}', "the model's answer is shown on failure");
|
||||
|
||||
// limits: AI_DAILY=5 overall; we've used 3
|
||||
answers.push({ response: good }, { response: good });
|
||||
assert.equal((await post("/api/spec", { prompt: "a" }, "2.2.2.2")).status, 200);
|
||||
assert.equal((await post("/api/spec", { prompt: "b" }, "2.2.2.2")).status, 200);
|
||||
assert.equal((await post("/api/spec", { prompt: "c" }, "3.3.3.3")).status, 429);
|
||||
assert.equal((await post("/api/spec", { prompt: "" })).status, 400);
|
||||
|
||||
// ---------- decision brains (Jev / Clef): choice answers -> spec, LLM only as the door ----------
|
||||
const D = await import(join(here, "decide.js"));
|
||||
const ans = (choice, confidence = 0.9, probabilities) => ({ type: "choice", choice, confidence, probabilities: probabilities || { [choice]: confidence } });
|
||||
const westTop3 = { mark: ans("bar", 0.8, { bar: 0.8, pie: 0.17, line: 0.03 }), x: ans("product"), measure: ans("amount"), agg: ans("sum"), series: ans("none"),
|
||||
order: ans("value_desc"), top: ans("3"), "only.region": ans("West", 0.95), "only.product": ans("any"), "only.channel": ans("any") };
|
||||
const infoNow = W.describe(data, sch.schema.columns);
|
||||
const qs = D.questions(infoNow);
|
||||
assert.deepEqual(Object.keys(qs).sort(), ["agg", "mark", "measure", "only.channel", "only.product", "only.region", "order", "series", "top", "x"]);
|
||||
assert.ok(!("id" in qs.x.criteria), "id is never an axis");
|
||||
assert.ok("rows" in qs.measure.criteria && "amount" in qs.measure.criteria && !("region" in qs.measure.criteria));
|
||||
assert.deepEqual(Object.keys(qs["only.region"].criteria).sort(), ["East", "North", "South", "West", "any"]);
|
||||
const asm = D.assemble(westTop3, infoNow);
|
||||
assert.deepEqual(asm.spec.transform, [{ op: "filter", field: "region", eq: "West" },
|
||||
{ op: "group", by: ["product"], agg: "sum", field: "amount", as: "value" }, { op: "sort", field: "value", order: "desc" }, { op: "limit", n: 3 }]);
|
||||
assert.equal(asm.spec.title, "Total amount by product (West)");
|
||||
assert.ok(lib.validateSpec(asm.spec, sch.schema).ok);
|
||||
assert.equal(lib.applySpec(data, asm.spec).length, 3);
|
||||
assert.deepEqual(asm.alternatives, [{ mark: "pie", p: 0.17 }]);
|
||||
assert.equal(asm.weakest, 0.8);
|
||||
// time on x sorts by time; a series only when asked; count needs no field
|
||||
const monthly = D.assemble({ ...westTop3, mark: ans("line"), x: ans("month"), measure: ans("rows"), agg: ans("avg", 0.1), series: ans("channel"),
|
||||
order: ans("natural"), top: ans("all"), "only.region": ans("any") }, infoNow);
|
||||
assert.deepEqual(monthly.spec.transform, [{ op: "group", by: ["month", "channel"], agg: "count", as: "value" }, { op: "sort", field: "month", order: "asc" }]);
|
||||
assert.equal(monthly.spec.series, "channel");
|
||||
assert.equal(monthly.weakest, 0.9, "agg doesn't gate when counting rows");
|
||||
assert.ok(lib.validateSpec(monthly.spec, sch.schema).ok);
|
||||
assert.equal(D.assemble({ ...westTop3, mark: ans("pie"), series: ans("channel") }, infoNow).spec.series, undefined, "no series on a pie");
|
||||
|
||||
// through the worker: a confident Jev answer is the spec, no LLM call
|
||||
const resetQuota = () => env.DB.db.exec("DELETE FROM quota");
|
||||
resetQuota(); calls.length = 0;
|
||||
decided.push({ model: "jev-1", answers: westTop3, usage: {} });
|
||||
r = await post("/api/spec", { dataset: "orders", prompt: "top 3 products in the West by revenue", brain: "jev" });
|
||||
let body = await r.json();
|
||||
assert.equal(r.status, 200, JSON.stringify(body));
|
||||
assert.equal(body.brain, "jev");
|
||||
assert.equal(decisions.at(-1).model, "typesafe/jev");
|
||||
assert.equal(decisions.at(-1).input.state.user_request, "top 3 products in the West by revenue");
|
||||
const toJev = JSON.stringify(decisions.at(-1).input), mid = data[200];
|
||||
assert.ok(!toJev.includes(mid.date) && !toJev.includes(String(mid.amount)) && !("sample" in decisions.at(-1).input.state),
|
||||
"rows never reach the decision model");
|
||||
assert.equal(calls.length, 0);
|
||||
assert.deepEqual(body.spec, asm.spec);
|
||||
// Clef names its variant in the input
|
||||
decided.push({ answers: westTop3 });
|
||||
await post("/api/spec", { dataset: "orders", prompt: "x", brain: "clef" });
|
||||
assert.equal(decisions.at(-1).model, "@cf/cloudflare/clef-flash");
|
||||
assert.equal(decisions.at(-1).input.model, "clef-flash");
|
||||
// unsure -> the door: the LLM answers, and the response says why
|
||||
decided.push({ answers: { ...westTop3, x: ans("product", 0.31) } });
|
||||
answers.push({ response: good });
|
||||
body = await (await post("/api/spec", { dataset: "orders", prompt: "hmm", brain: "jev" })).json();
|
||||
assert.equal(body.brain, "llm");
|
||||
assert.match(body.handoff.reason, /low confidence \(0.31 on x\)/);
|
||||
assert.equal(body.handoff.confidences.x, 0.31);
|
||||
assert.deepEqual(body.spec, good);
|
||||
// the same answer with the caller's own lower bar is accepted, with every confidence shown
|
||||
decided.push({ answers: { ...westTop3, x: ans("product", 0.31) } });
|
||||
body = await (await post("/api/spec", { dataset: "orders", prompt: "hmm", brain: "jev", floor: 0.2 })).json();
|
||||
assert.equal(body.brain, "jev");
|
||||
assert.equal(body.confidences.x, 0.31);
|
||||
// decision model down -> the door too
|
||||
decided.push(new Error("no such model"));
|
||||
answers.push({ response: good });
|
||||
body = await (await post("/api/spec", { dataset: "orders", prompt: "hmm", brain: "jev" })).json();
|
||||
assert.match(body.handoff.reason, /decision model error: no such model/);
|
||||
// the server default brain, and an unknown brain falls back to it
|
||||
resetQuota(); env.BRAIN = "jev"; decided.push({ answers: westTop3 });
|
||||
assert.equal((await (await post("/api/spec", { prompt: "q", brain: "gpt" })).json()).brain, "jev");
|
||||
delete env.BRAIN; resetQuota();
|
||||
|
||||
// save, list, load; only valid specs are stored
|
||||
r = await post("/api/charts", { dataset: "orders", prompt: "revenue by region", spec: good });
|
||||
assert.equal(r.status, 201);
|
||||
const { id, link } = await r.json();
|
||||
assert.equal(link, `https://aicharts.t.workers.dev/?c=${id}`);
|
||||
assert.equal((await post("/api/charts", { dataset: "orders", spec: { mark: "bar", x: "nope", y: "amount" } })).status, 400);
|
||||
const list = (await (await call("/api/charts")).json()).charts;
|
||||
assert.deepEqual(list.map((c) => [c.id, c.title]), [[id, "Revenue by region"]]);
|
||||
const back = await (await call("/api/charts/" + id)).json();
|
||||
assert.deepEqual(back.spec, good);
|
||||
assert.equal((await call("/api/charts/zzzzzz")).status, 404);
|
||||
|
||||
// read-only: the only tables written after seeding are quota and saved_charts
|
||||
const after = env.DB.writes.slice(env.DB.writes.lastIndexOf("INSERT OR IGNORE INTO orders") + 1);
|
||||
assert.ok(after.every((w) => /^(CREATE TABLE IF NOT EXISTS|INSERT INTO quota|INSERT INTO saved_charts)/.test(w)), after.join(", "));
|
||||
|
||||
// the saved spec renders with the library against the real rows
|
||||
const out = lib.applySpec(data, back.spec);
|
||||
assert.deepEqual(out.map((x) => x.region).sort(), ["East", "North", "South", "West"]);
|
||||
assert.equal(Math.round(out.reduce((a, x) => a + x.value, 0)), Math.round(data.reduce((a, x) => a + x.amount, 0)));
|
||||
|
||||
console.log("aicharts: all good");
|
||||
@@ -0,0 +1,358 @@
|
||||
// AICharts demo Worker: the page, the library, a sample dataset in D1, the spec endpoint (Workers AI) and saved charts.
|
||||
//
|
||||
// GET / the demo page (Chart.js + <ai-chart>)
|
||||
// GET /aicharts.js the library, as served to any page that embeds it
|
||||
// GET /api/schema?dataset=orders columns, a few sample rows and aggregates: all the model ever sees
|
||||
// GET /api/data?dataset=orders the rows, for the browser to chart
|
||||
// POST /api/spec { dataset, prompt, prior? } -> { spec } a validated chart spec, never code
|
||||
// POST /api/charts { dataset, prompt, spec } -> { id, link } save a chart
|
||||
// GET /api/charts, /api/charts/:id the saved charts
|
||||
//
|
||||
// Read-only except saved_charts and quota. Bindings: DB (D1), AI (Workers AI). Vars: AI_MODEL, AI_DAILY, SAVE_DAILY, REV.
|
||||
// deploy.py uploads main.js, which hands this module the library source: export default make(src).
|
||||
import { validateSpec, MARKS, AGGS } from "./aicharts.js";
|
||||
import { decide } from "./decide.js";
|
||||
|
||||
const SCHEMA = [
|
||||
"CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, date TEXT, month TEXT, region TEXT, product TEXT, channel TEXT, units INTEGER, amount REAL)",
|
||||
"CREATE TABLE IF NOT EXISTS quota (k TEXT PRIMARY KEY, n INTEGER NOT NULL)",
|
||||
"CREATE TABLE IF NOT EXISTS saved_charts (id TEXT PRIMARY KEY, created TEXT NOT NULL, dataset TEXT NOT NULL, prompt TEXT, title TEXT, spec TEXT NOT NULL)",
|
||||
];
|
||||
const COLUMNS = [
|
||||
{ name: "id", type: "number" }, { name: "date", type: "date" }, { name: "month", type: "month" },
|
||||
{ name: "region", type: "string" }, { name: "product", type: "string" }, { name: "channel", type: "string" },
|
||||
{ name: "units", type: "number" }, { name: "amount", type: "number" },
|
||||
];
|
||||
const DATASETS = { orders: { title: "Orders (sample shop, 24 months)", columns: COLUMNS } };
|
||||
const MAX_PROMPT = 400;
|
||||
|
||||
// The sample shop: deterministic, so every deploy seeds the same rows and tests can count on them.
|
||||
export function seedOrders() {
|
||||
let s = 42;
|
||||
const rnd = () => { s = (s + 0x6d2b79f5) | 0; let t = Math.imul(s ^ (s >>> 15), 1 | s); t ^= t + Math.imul(t ^ (t >>> 7), 61 | t); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
|
||||
const regions = ["North", "South", "East", "West"], channels = ["web", "store"];
|
||||
const products = [["Matcha", 24], ["Sencha", 14], ["Hojicha", 12], ["Teapot", 48], ["Whisk", 18]];
|
||||
const rows = [];
|
||||
for (let m = 0; m < 24; m++) {
|
||||
const y = 2024 + Math.floor((m + 9) / 12), mo = ((m + 9) % 12) + 1, month = `${y}-${String(mo).padStart(2, "0")}`;
|
||||
const season = 1 + 0.25 * Math.sin((mo / 12) * 2 * Math.PI) + m * 0.015;
|
||||
for (const region of regions) for (const [product, price] of products) {
|
||||
const units = Math.max(1, Math.round((8 + rnd() * 30) * season * (region === "West" ? 1.3 : 1)));
|
||||
const channel = rnd() < (product === "Teapot" ? 0.4 : 0.65) ? "web" : "store";
|
||||
const day = String(1 + Math.floor(rnd() * 28)).padStart(2, "0");
|
||||
rows.push({ id: rows.length + 1, date: `${month}-${day}`, month, region, product, channel, units,
|
||||
amount: Math.round(units * price * (0.9 + rnd() * 0.2) * 100) / 100 });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
let ready = null;
|
||||
async function setup(env) {
|
||||
ready ??= (async () => {
|
||||
await env.DB.batch(SCHEMA.map((q) => env.DB.prepare(q)));
|
||||
const n = await env.DB.prepare("SELECT COUNT(*) AS n FROM orders").first();
|
||||
if (!n?.n) {
|
||||
const ins = env.DB.prepare("INSERT OR IGNORE INTO orders (id, date, month, region, product, channel, units, amount) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
|
||||
const rows = seedOrders();
|
||||
for (let i = 0; i < rows.length; i += 100)
|
||||
await env.DB.batch(rows.slice(i, i + 100).map((r) => ins.bind(r.id, r.date, r.month, r.region, r.product, r.channel, r.units, r.amount)));
|
||||
}
|
||||
})().catch((e) => { ready = null; throw e; });
|
||||
return ready;
|
||||
}
|
||||
|
||||
const CORS = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET, POST, OPTIONS",
|
||||
"access-control-allow-headers": "content-type" };
|
||||
const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8", ...CORS } });
|
||||
|
||||
async function who(req) {
|
||||
const ip = req.headers.get("cf-connecting-ip") || "?";
|
||||
const raw = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`aicharts:${ip}`));
|
||||
return [...new Uint8Array(raw).slice(0, 8)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
// One counter per (day, kind, person) and one per (day, kind): over either limit means no.
|
||||
async function spend(env, kind, person, perPerson, total) {
|
||||
const day = new Date().toISOString().slice(0, 10);
|
||||
const bump = (k) => env.DB.prepare("INSERT INTO quota (k, n) VALUES (?, 1) ON CONFLICT(k) DO UPDATE SET n = n + 1 RETURNING n").bind(k);
|
||||
const [mine, all] = await env.DB.batch([bump(`${day}:${kind}:${person}`), bump(`${day}:${kind}:*`)]);
|
||||
return mine.results[0].n <= perPerson && all.results[0].n <= total;
|
||||
}
|
||||
|
||||
async function rows(env) {
|
||||
return (await env.DB.prepare("SELECT id, date, month, region, product, channel, units, amount FROM orders ORDER BY id").all()).results;
|
||||
}
|
||||
|
||||
// What the model sees: columns, five rows, distinct values of text columns, ranges of number columns.
|
||||
export function describe(data, columns) {
|
||||
const aggregates = {};
|
||||
for (const c of columns) {
|
||||
const vals = data.map((r) => r[c.name]);
|
||||
if (c.type === "number") aggregates[c.name] = { min: Math.min(...vals), max: Math.max(...vals) };
|
||||
else {
|
||||
const d = [...new Set(vals)].sort();
|
||||
aggregates[c.name] = d.length <= 20 ? { values: d } : { first: d[0], last: d.at(-1), distinct: d.length };
|
||||
}
|
||||
}
|
||||
return { columns, sample: data.slice(0, 5), aggregates, rows: data.length };
|
||||
}
|
||||
|
||||
export function systemPrompt(dataset, info) {
|
||||
return `You turn a question about a table into a chart spec. Answer with ONE JSON object and nothing else.
|
||||
Table "${dataset}" (${info.rows} rows). Columns: ${info.columns.map((c) => `${c.name} (${c.type})`).join(", ")}.
|
||||
Values: ${JSON.stringify(info.aggregates)}
|
||||
Sample rows: ${JSON.stringify(info.sample)}
|
||||
|
||||
Spec: {"title": text, "mark": ${MARKS.map((m) => `"${m}"`).join(" | ")}, "transform": [steps, applied in order, at most 8],
|
||||
"x": field, "y": number field, "series": optional field (one line/bar colour per value)}
|
||||
Steps:
|
||||
{"op": "filter", "field": f, "eq": v} or "in": [v, ...] or "gte": v and/or "lte": v
|
||||
{"op": "group", "by": [up to 2 fields], "agg": ${AGGS.map((a) => `"${a}"`).join(" | ")}, "field": number field (not for count), "as": "value"}
|
||||
after a group, only the "by" fields and "as" remain
|
||||
{"op": "sort", "field": f, "order": "asc" | "desc"}
|
||||
{"op": "bin", "field": number field, "step": n, "as": name}
|
||||
{"op": "limit", "n": 1..500}
|
||||
Use "month" for time on x and sort it asc. Use "series" with group by [x, series] to compare. Pie only for shares of a whole.
|
||||
Example: {"title": "Revenue by region", "mark": "bar", "transform": [{"op": "group", "by": ["region"], "agg": "sum", "field": "amount", "as": "value"}, {"op": "sort", "field": "value", "order": "desc"}], "x": "region", "y": "value"}`;
|
||||
}
|
||||
|
||||
// The first balanced {...} in a model's answer (models wrap JSON in prose or fences when they feel like it).
|
||||
export function firstObject(text) {
|
||||
if (text && typeof text === "object") return text;
|
||||
const s = String(text ?? "");
|
||||
for (let i = s.indexOf("{"); i >= 0; i = s.indexOf("{", i + 1)) {
|
||||
let depth = 0, str = false, esc = false;
|
||||
for (let j = i; j < s.length; j++) {
|
||||
const ch = s[j];
|
||||
if (str) { if (esc) esc = false; else if (ch === "\\") esc = true; else if (ch === '"') str = false; continue; }
|
||||
if (ch === '"') str = true;
|
||||
else if (ch === "{") depth++;
|
||||
else if (ch === "}" && --depth === 0) {
|
||||
try { return JSON.parse(s.slice(i, j + 1)); } catch { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Small models drift on spelling, not meaning: "SUM", "mean", "aggregate", "groupBy": "region". Fix the spelling, then
|
||||
// the validator decides. Nothing here invents a field or an operation.
|
||||
const AGG_ALIAS = { average: "avg", mean: "avg", total: "sum", minimum: "min", maximum: "max", cnt: "count" };
|
||||
export function normalizeSpec(spec) {
|
||||
if (!spec || typeof spec !== "object" || Array.isArray(spec)) return spec;
|
||||
const low = (v) => (typeof v === "string" ? v.trim().toLowerCase() : v);
|
||||
const out = { ...spec, mark: low(spec.mark) };
|
||||
if (spec.chart && !spec.mark) out.mark = low(spec.chart.type ?? spec.chart);
|
||||
if (Array.isArray(spec.transform)) out.transform = spec.transform.map((s0) => {
|
||||
if (!s0 || typeof s0 !== "object") return s0;
|
||||
const s = { ...s0, op: low(s0.op ?? s0.type) };
|
||||
if (s.op === "groupby" || s.op === "aggregate") s.op = "group";
|
||||
if (s.aggregate != null && s.agg == null) s.agg = s.aggregate;
|
||||
if (s.groupby != null && s.by == null) s.by = s.groupby;
|
||||
if (s.groupBy != null && s.by == null) s.by = s.groupBy;
|
||||
if (typeof s.by === "string") s.by = [s.by];
|
||||
if (s.agg != null) { s.agg = low(s.agg); s.agg = AGG_ALIAS[s.agg] ?? s.agg; }
|
||||
if (s.order != null) s.order = low(s.order);
|
||||
delete s.type; delete s.aggregate; delete s.groupby; delete s.groupBy;
|
||||
return s;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// Plain prompt first: the prompt's grammar and example steer small models better than a constrained decoder does (a
|
||||
// one-size schema for every step made them drop agg and by). JSON mode only when no object came back at all.
|
||||
async function model(env, messages) {
|
||||
const input = { messages, max_tokens: 900, temperature: 0.1 };
|
||||
const text = (out) => out?.response ?? out?.choices?.[0]?.message?.content ?? "";
|
||||
let raw = text(await env.AI.run(env.AI_MODEL, input));
|
||||
if (!firstObject(raw)) {
|
||||
try { raw = text(await env.AI.run(env.AI_MODEL, { ...input, response_format: { type: "json_object" } })); } catch { /* model has no JSON mode */ }
|
||||
}
|
||||
return { spec: normalizeSpec(firstObject(raw)), raw: typeof raw === "string" ? raw : JSON.stringify(raw) };
|
||||
}
|
||||
|
||||
// Ask, check, and give the model one chance to fix what the validator found.
|
||||
// brain: "llm" writes the spec; "jev" / "clef" answer choice questions and decide.js assembles it. A decision that
|
||||
// fails validation, or whose weakest needed answer is under FLOOR, goes to the LLM: the cage with a door.
|
||||
export const BRAINS = ["llm", "jev", "clef"];
|
||||
async function specVia(env, dataset, prompt, prior, brain, floorAsked) {
|
||||
const info = describe(await rows(env), DATASETS[dataset].columns);
|
||||
const schema = { columns: info.columns };
|
||||
const t0 = Date.now();
|
||||
let handoff;
|
||||
if (brain !== "llm") {
|
||||
const model = brain === "jev" ? env.JEV_MODEL || "typesafe/jev" : env.CLEF_MODEL || "@cf/cloudflare/clef-flash";
|
||||
try {
|
||||
const d = await decide(env, model, info, prompt, prior && validateSpec(prior, schema).ok ? prior : undefined);
|
||||
// a caller may lower or raise the bar for itself (benchmarks; a page that prefers speed); it only changes cost
|
||||
const floor = Number.isFinite(floorAsked) && floorAsked >= 0 && floorAsked <= 1 ? floorAsked : Number(env.FLOOR || 0.5);
|
||||
const v = validateSpec(d.spec, schema);
|
||||
const confidences = Object.fromEntries(Object.entries(d.confidence).map(([k, c]) => [k, Math.round(c * 100) / 100]));
|
||||
if (v.ok && d.weakest >= floor)
|
||||
return { spec: d.spec, brain, model: d.model, ms: d.ms, confidence: d.weakest, confidences, alternatives: d.alternatives };
|
||||
handoff = { reason: v.ok ? `low confidence (${d.weakest} on ${d.weakestOn})` : "decision failed checks: " + v.errors[0],
|
||||
decided: d.spec, ms: d.ms, confidences };
|
||||
} catch (e) {
|
||||
handoff = { reason: "decision model error: " + String(e.message || e).slice(0, 160) };
|
||||
}
|
||||
}
|
||||
const out = await specFor(env, dataset, prompt, prior, info);
|
||||
return { ...out, brain: "llm", model: env.AI_MODEL, ms: Date.now() - t0, ...(handoff ? { handoff } : {}) };
|
||||
}
|
||||
|
||||
async function specFor(env, dataset, prompt, prior, known) {
|
||||
const info = known || describe(await rows(env), DATASETS[dataset].columns);
|
||||
const schema = { columns: info.columns };
|
||||
const messages = [{ role: "system", content: systemPrompt(dataset, info) }];
|
||||
if (prior && validateSpec(prior, schema).ok)
|
||||
messages.push({ role: "user", content: "Current chart spec (change it if the question builds on it): " + JSON.stringify(prior) });
|
||||
messages.push({ role: "user", content: prompt });
|
||||
let { spec, raw } = await model(env, messages), v = validateSpec(spec, schema);
|
||||
if (!v.ok) {
|
||||
messages.push({ role: "assistant", content: raw.slice(0, 2000) },
|
||||
{ role: "user", content: `That spec fails these checks: ${v.errors.join("; ")}. Answer with the corrected JSON object only.` });
|
||||
({ spec, raw } = await model(env, messages));
|
||||
v = validateSpec(spec, schema);
|
||||
}
|
||||
// On failure the model's own answer comes back too: it is shown, never run.
|
||||
return v.ok ? { spec } : { error: "couldn't turn that into a valid chart: " + v.errors.slice(0, 3).join("; "), model_said: raw.slice(0, 800) };
|
||||
}
|
||||
|
||||
const newId = () => [...crypto.getRandomValues(new Uint8Array(6))].map((b) => b.toString(36).padStart(2, "0")).join("").slice(0, 10);
|
||||
|
||||
async function api(req, env, url) {
|
||||
const path = url.pathname;
|
||||
if (path === "/api/health") return json({ ok: true, rev: env.REV || "", ai: !!env.AI });
|
||||
await setup(env);
|
||||
const dataset = url.searchParams.get("dataset") || "orders";
|
||||
if (req.method === "GET" && (path === "/api/schema" || path === "/api/data")) {
|
||||
if (!DATASETS[dataset]) return json({ error: "no such dataset" }, 404);
|
||||
const data = await rows(env);
|
||||
if (path === "/api/data") return json({ dataset, rows: data });
|
||||
const { columns, sample, aggregates } = describe(data, DATASETS[dataset].columns);
|
||||
return json({ dataset, title: DATASETS[dataset].title, schema: { columns }, sample, aggregates });
|
||||
}
|
||||
if (req.method === "POST" && (path === "/api/spec" || path === "/api/charts")) {
|
||||
let body;
|
||||
try { body = await req.json(); } catch { return json({ error: "send JSON" }, 400); }
|
||||
const ds = body?.dataset || "orders";
|
||||
if (!DATASETS[ds]) return json({ error: "no such dataset" }, 404);
|
||||
const prompt = String(body.prompt || "").trim().slice(0, MAX_PROMPT);
|
||||
if (path === "/api/spec") {
|
||||
if (!env.AI) return json({ error: "no AI here" }, 501);
|
||||
if (!prompt) return json({ error: "ask a question" }, 400);
|
||||
if (!(await spend(env, "ai", await who(req), 60, Number(env.AI_DAILY || 300)))) return json({ error: "over today's limit, try tomorrow" }, 429);
|
||||
const brain = BRAINS.includes(body.brain) ? body.brain : BRAINS.includes(env.BRAIN) ? env.BRAIN : "llm";
|
||||
const out = await specVia(env, ds, prompt, body.prior, brain, body.floor == null ? undefined : Number(body.floor));
|
||||
return json(out, out.spec ? 200 : 422);
|
||||
}
|
||||
const v = validateSpec(body.spec, { columns: DATASETS[ds].columns });
|
||||
if (!v.ok) return json({ error: "that spec doesn't validate: " + v.errors.join("; ") }, 400);
|
||||
if (!(await spend(env, "save", await who(req), 50, Number(env.SAVE_DAILY || 1000)))) return json({ error: "over today's limit" }, 429);
|
||||
const id = newId(), title = String(body.spec.title || prompt || "Chart").slice(0, 120);
|
||||
await env.DB.prepare("INSERT INTO saved_charts (id, created, dataset, prompt, title, spec) VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.bind(id, new Date().toISOString(), ds, prompt, title, JSON.stringify(body.spec)).run();
|
||||
return json({ id, link: `${url.origin}/?c=${id}` }, 201);
|
||||
}
|
||||
if (req.method === "GET" && path === "/api/charts") {
|
||||
const list = (await env.DB.prepare("SELECT id, created, dataset, prompt, title FROM saved_charts ORDER BY created DESC LIMIT 20").all()).results;
|
||||
return json({ charts: list });
|
||||
}
|
||||
const one = path.match(/^\/api\/charts\/([a-z0-9]{4,16})$/);
|
||||
if (one && req.method === "GET") {
|
||||
const r = await env.DB.prepare("SELECT id, created, dataset, prompt, title, spec FROM saved_charts WHERE id = ?").bind(one[1]).first();
|
||||
return r ? json({ ...r, spec: JSON.parse(r.spec) }) : json({ error: "not found" }, 404);
|
||||
}
|
||||
return json({ error: "no such endpoint" }, 404);
|
||||
}
|
||||
|
||||
export const PAGE = `<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AICharts</title>
|
||||
<meta name="description" content="Ask for a chart in plain words. The model writes a small checked spec, never code, and never sees your rows.">
|
||||
<style>
|
||||
:root{--bg:#faf9f6;--fg:#1d1d1b;--mute:#6b6a65;--line:#e3e1da;--card:#fff;--accent:#2f6fdf}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#161614;--fg:#ecebe6;--mute:#9b9a94;--line:#2c2b28;--card:#1f1f1c;--accent:#7aa5ff}}
|
||||
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,sans-serif}
|
||||
main{max-width:860px;margin:0 auto;padding:32px 16px 64px}h1{margin:0 0 4px;font-size:28px}p.lede{margin:0 0 20px;color:var(--mute)}
|
||||
.aic-ask{display:flex;gap:8px}.aic-ask input{flex:1;min-width:0;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--fg);font:inherit}
|
||||
button{padding:10px 14px;border:0;border-radius:8px;background:var(--accent);color:#fff;font:inherit;cursor:pointer}
|
||||
.aic-msg{min-height:24px;margin:10px 0;color:var(--mute)}.aic-canvas{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px}
|
||||
.aic-save{margin-top:10px}.chips{display:flex;flex-wrap:wrap;gap:6px;margin:12px 0}.chips button{background:var(--card);color:var(--fg);border:1px solid var(--line);padding:6px 10px;font-size:14px}
|
||||
details{margin-top:16px}pre{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:12px;overflow:auto;font-size:13px}
|
||||
h2{font-size:18px;margin:28px 0 8px}.row{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:space-between}
|
||||
.brain{font-size:14px;color:var(--mute)}.brain select{font:inherit;padding:4px;border-radius:6px;border:1px solid var(--line);background:var(--card);color:var(--fg)}
|
||||
.meta{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-top:10px;font-size:14px;color:var(--mute)}.meta button{padding:4px 10px;font-size:14px;background:var(--card);color:var(--fg);border:1px solid var(--line)}ul{padding-left:18px}a{color:var(--accent)}
|
||||
</style></head><body><main>
|
||||
<h1>AICharts</h1>
|
||||
<p class="lede">Ask for a chart in plain words. The model writes a small spec that is checked before anything draws, and never sees the rows: only column names, five sample rows and value ranges. Data: a sample tea shop, 24 months of orders.</p>
|
||||
<div class="row"><div class="chips" id="chips"></div><label class="brain">Brain <select id="brain"><option value="">default</option><option value="jev">Jev</option><option value="clef">Clef</option><option value="llm">LLM</option></select></label></div>
|
||||
<ai-chart dataset="orders"></ai-chart>
|
||||
<div class="meta" id="meta"></div>
|
||||
<details><summary>Spec</summary><pre id="spec">Ask something first.</pre></details>
|
||||
<h2>Saved charts</h2><ul id="saved"><li>None yet.</li></ul>
|
||||
<h2>Use it on your page</h2>
|
||||
<pre><script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
|
||||
<script type="module" src="__ORIGIN__/aicharts.js"></script>
|
||||
<ai-chart endpoint="__ORIGIN__" dataset="orders"></ai-chart></pre>
|
||||
</main>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
|
||||
<script type="module">
|
||||
import "/aicharts.js";
|
||||
const el = document.querySelector("ai-chart"), list = document.getElementById("saved");
|
||||
for (const q of ["Revenue by region", "Monthly revenue, web vs store", "Share of units by product", "Top 3 products in the West by revenue"]) {
|
||||
const b = document.createElement("button"); b.textContent = q; b.onclick = () => { el.querySelector("input").value = q; el.ask(q); };
|
||||
document.getElementById("chips").append(b);
|
||||
}
|
||||
const brainSel = document.getElementById("brain"), meta = document.getElementById("meta");
|
||||
brainSel.onchange = () => brainSel.value ? el.setAttribute("brain", brainSel.value) : el.removeAttribute("brain");
|
||||
el.addEventListener("spec", (e) => {
|
||||
const { spec, info = {} } = e.detail;
|
||||
document.getElementById("spec").textContent = JSON.stringify({ ...info, spec }, null, 2);
|
||||
const bits = [info.brain && "via " + info.brain + (info.handoff ? " (handed off: " + info.handoff.reason + ")" : ""),
|
||||
info.ms != null && info.ms + " ms", info.confidence != null && "confidence " + info.confidence].filter(Boolean);
|
||||
meta.replaceChildren(document.createTextNode(bits.join(" · ")));
|
||||
for (const alt of info.alternatives || []) {
|
||||
const b = document.createElement("button"); b.textContent = "or as " + alt.mark + " (" + Math.round(alt.p * 100) + "%)";
|
||||
b.onclick = () => el.render({ ...spec, mark: alt.mark }, e.detail.prompt).catch((err) => { meta.append(" " + err.message); });
|
||||
meta.append(b);
|
||||
}
|
||||
});
|
||||
el.onSave = async (d) => {
|
||||
const r = await fetch("/api/charts", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(d) });
|
||||
const out = await r.json(); if (!r.ok) throw new Error(out.error); history.replaceState(null, "", "?c=" + out.id); await refresh();
|
||||
};
|
||||
async function show(id) {
|
||||
const r = await fetch("/api/charts/" + id); if (!r.ok) return;
|
||||
const c = await r.json(); await el.render(c.spec, c.prompt); document.getElementById("spec").textContent = JSON.stringify(c.spec, null, 2);
|
||||
el.querySelector("input").value = c.prompt || "";
|
||||
}
|
||||
async function refresh() {
|
||||
const { charts } = await (await fetch("/api/charts")).json();
|
||||
list.replaceChildren(...(charts.length ? charts.map((c) => { const li = document.createElement("li"), a = document.createElement("a");
|
||||
a.href = "?c=" + c.id; a.textContent = c.title; a.onclick = (e) => { e.preventDefault(); history.replaceState(null, "", "?c=" + c.id); show(c.id); };
|
||||
li.append(a); return li; }) : [Object.assign(document.createElement("li"), { textContent: "None yet." })]));
|
||||
}
|
||||
const id = new URLSearchParams(location.search).get("c");
|
||||
customElements.whenDefined("ai-chart").then(() => { refresh(); if (id) show(id); });
|
||||
</script></body></html>`;
|
||||
|
||||
export function make(librarySource) {
|
||||
return {
|
||||
async fetch(req, env) {
|
||||
const url = new URL(req.url);
|
||||
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS });
|
||||
try {
|
||||
if (url.pathname.startsWith("/api/")) return await api(req, env, url);
|
||||
} catch (e) {
|
||||
return json({ error: "something broke: " + String(e.message || e).slice(0, 160) }, 500);
|
||||
}
|
||||
if (url.pathname === "/aicharts.js")
|
||||
return new Response(librarySource, { headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "public, max-age=300", ...CORS } });
|
||||
if (url.pathname === "/" || url.pathname === "/index.html")
|
||||
return new Response(PAGE.replaceAll("__ORIGIN__", url.origin), { headers: { "content-type": "text/html; charset=utf-8" } });
|
||||
return new Response("not found", { status: 404 });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -10,5 +10,9 @@
|
||||
"glm": {"family": "glm", "default": "z-ai-glm-5-3", "label": "Zhipu GLM via Venice",
|
||||
"aliases": {"flash": "z-ai-glm-5-3-flash"}},
|
||||
"gemini": {"family": "gemini", "default": "gemini-3-1-pro-preview", "label": "Google Gemini via Venice",
|
||||
"aliases": {"pro": "gemini-3-1-pro-preview", "flash": "gemini-3-8-flash"}}
|
||||
"aliases": {"pro": "gemini-3-1-pro-preview", "flash": "gemini-3-8-flash"}},
|
||||
"haiku": {"family": "claude-haiku", "default": "claude-haiku-5-5", "label": "Claude Haiku (Anthropic)", "provider": "anthropic", "price": [0.10, 0.50]},
|
||||
"sonnet": {"family": "claude-sonnet", "default": "claude-sonnet-5-5", "label": "Claude Sonnet (Anthropic)", "provider": "anthropic", "price": [2, 10]},
|
||||
"opus": {"family": "claude-opus", "default": "claude-opus-5-5", "label": "Claude Opus (Anthropic)", "provider": "anthropic", "price": [4, 20]},
|
||||
"fable": {"family": "claude-fable", "default": "claude-fable-5-1", "label": "Claude Fable (Anthropic)", "provider": "anthropic", "price": [10, 50]}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Seed a venture on demand: land a site built elsewhere on gh-pages at ventures/<slug>/, which Teahub pages serves at
|
||||
<slug>.yolopreneur.lol, and keep the venture's line in the ledger (ventures/ventures.json).
|
||||
|
||||
Run by .github/workflows/seed.yml (Actions -> seed -> Run workflow). Its inputs arrive in the environment:
|
||||
|
||||
SLUG the folder and the subdomain
|
||||
BUNDLE one or more https URLs, space-separated. Downloaded in order and joined, they make one .tgz whose
|
||||
top-level folder is <slug>/ (a big bundle can come as parts, e.g. Boring blob download links)
|
||||
SHA sha256 of the joined .tgz: nothing lands unless it matches
|
||||
NAME, ONE_LINER the ledger line; a venture already in the ledger keeps whatever is left empty
|
||||
|
||||
Nothing in the bundle runs. It is checked (paths, file types, size, an index.html) and copied over ventures/<slug>/,
|
||||
replacing what was there, in one commit pushed to gh-pages by the Actions identity.
|
||||
|
||||
python3 seed.py PAGES_DIR # PAGES_DIR: a checkout of gh-pages
|
||||
"""
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
MAX_BYTES = 64 * 1024 * 1024
|
||||
SLUG = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,40}[a-z0-9])?")
|
||||
RESERVED = {"index.html", "ventures.json", "backlog.json", "shots", "assets", "www"}
|
||||
SITE = "https://{slug}.yolopreneur.lol/"
|
||||
|
||||
|
||||
class SeedError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def fetch(urls):
|
||||
"""the bundle: every URL in order, joined"""
|
||||
data = bytearray()
|
||||
for u in urls:
|
||||
if not u.startswith("https://"):
|
||||
raise SeedError(f"not an https URL: {u[:60]}")
|
||||
with urllib.request.urlopen(u, timeout=120) as r:
|
||||
data += r.read(MAX_BYTES + 1 - len(data))
|
||||
if len(data) > MAX_BYTES:
|
||||
raise SeedError(f"bundle is over {MAX_BYTES // 2**20} MB")
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def unpack(data, slug, into):
|
||||
"""write the bundle's <slug>/ tree under `into`, refusing anything that isn't a plain file or folder inside it"""
|
||||
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tf:
|
||||
members = tf.getmembers()
|
||||
for m in members:
|
||||
parts = Path(m.name).parts
|
||||
if m.name.startswith("/") or ".." in parts:
|
||||
raise SeedError(f"unsafe path in bundle: {m.name}")
|
||||
if not (m.isfile() or m.isdir()):
|
||||
raise SeedError(f"not a plain file or folder: {m.name}")
|
||||
if not parts or parts[0] != slug:
|
||||
raise SeedError(f"everything in the bundle must sit under {slug}/: {m.name}")
|
||||
for m in members:
|
||||
target = into / m.name
|
||||
if m.isdir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(tf.extractfile(m).read())
|
||||
if not (into / slug / "index.html").is_file():
|
||||
raise SeedError(f"the bundle has no {slug}/index.html")
|
||||
|
||||
|
||||
def ledger_line(data, slug, name, one_liner, today):
|
||||
"""the venture's line in the ledger, added (at the top) or refreshed; raises before anything is written"""
|
||||
vs = data.setdefault("ventures", [])
|
||||
v = next((v for v in vs if v.get("slug") == slug), None)
|
||||
if v is None:
|
||||
if not name or not one_liner:
|
||||
raise SeedError("a new venture needs a name and a one-liner for the ledger")
|
||||
v = {"slug": slug, "name": name, "one_liner": one_liner, "url": SITE.format(slug=slug),
|
||||
"pages": SITE.format(slug=slug), "launched": today, "signups": 0, "source": "yolopreneur"}
|
||||
vs.insert(0, v)
|
||||
else:
|
||||
if name:
|
||||
v["name"] = name
|
||||
if one_liner:
|
||||
v["one_liner"] = one_liner
|
||||
return v
|
||||
|
||||
|
||||
def land(pages, slug, data, want, name="", one_liner="", today=None):
|
||||
"""check the bundle and put it at ventures/<slug>/ in the gh-pages checkout `pages`; returns the bundle's sha256.
|
||||
Every check runs before anything is written, so a refused bundle leaves the checkout as it was."""
|
||||
if not SLUG.fullmatch(slug or "") or slug in RESERVED:
|
||||
raise SeedError(f"bad slug: {slug!r}")
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", (want or "").lower()):
|
||||
raise SeedError("sha256 must be 64 hex characters")
|
||||
got = hashlib.sha256(data).hexdigest()
|
||||
if got != want.lower():
|
||||
raise SeedError(f"sha256 mismatch: the bundle is {got}")
|
||||
path = pages / "ventures" / "ventures.json"
|
||||
book = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {"ventures": []}
|
||||
line = ledger_line(book, slug, name, one_liner, today or dt.date.today().isoformat())
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
unpack(data, slug, Path(tmp))
|
||||
dest = pages / "ventures" / slug
|
||||
shutil.rmtree(dest, ignore_errors=True)
|
||||
shutil.copytree(Path(tmp) / slug, dest)
|
||||
if (dest / "shots" / "desktop.png").is_file():
|
||||
line["shot"] = f"{slug}/shots/desktop.png" # the card on the yolopreneur.lol index
|
||||
path.write_text(json.dumps(book, indent=1), encoding="utf-8") # the ledger's own layout: indent 1, ASCII
|
||||
return got
|
||||
|
||||
|
||||
def commit(pages, slug, sha):
|
||||
git = ["git", "-C", str(pages), "-c", "user.name=github-actions[bot]",
|
||||
"-c", "user.email=github-actions[bot]@users.noreply.teahub.xyz"]
|
||||
subprocess.run(git + ["add", "--all", f"ventures/{slug}", "ventures/ventures.json"], check=True)
|
||||
if subprocess.run(git + ["diff", "--cached", "--quiet"]).returncode == 0:
|
||||
print("nothing changed: ventures/" + slug + " is already this bundle")
|
||||
return False
|
||||
subprocess.run(git + ["commit", "-q", "-m", f"ventures: seed {slug} (bundle {sha[:12]})"], check=True)
|
||||
if subprocess.run(git + ["push", "-q", "origin", "HEAD:gh-pages"]).returncode != 0:
|
||||
# gh-pages moved under us (the venture loop writes there too): replay this one commit on top, push again
|
||||
subprocess.run(git + ["pull", "-q", "--rebase", "origin", "gh-pages"], check=True)
|
||||
subprocess.run(git + ["push", "-q", "origin", "HEAD:gh-pages"], check=True)
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
pages = Path(sys.argv[1] if len(sys.argv) > 1 else ".")
|
||||
slug = os.environ.get("SLUG", "").strip()
|
||||
try:
|
||||
data = fetch(os.environ.get("BUNDLE", "").split())
|
||||
sha = land(pages, slug, data, os.environ.get("SHA", "").strip(), os.environ.get("NAME", "").strip(),
|
||||
os.environ.get("ONE_LINER", "").strip())
|
||||
except (SeedError, OSError, tarfile.TarError) as e:
|
||||
print(f"::error::seed {slug}: {e}")
|
||||
sys.exit(1)
|
||||
pushed = commit(pages, slug, sha)
|
||||
print(f"{'landed' if pushed else 'unchanged'}: ventures/{slug}/ ({len(data)} bytes, sha256 {sha[:12]}) -> "
|
||||
+ SITE.format(slug=slug))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for seed.py: a bundle lands only when it is what it says it is, and the ledger keeps its layout."""
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
spec = importlib.util.spec_from_file_location("seed", Path(__file__).with_name("seed.py"))
|
||||
seed = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(seed)
|
||||
|
||||
LEDGER = {"ventures": [{"slug": "mdecks", "name": ".mdeck", "one_liner": "Slides in plain text — fast.",
|
||||
"launched": "2026-10-12", "signups": 0}], "refreshed": "2026-10-14"}
|
||||
|
||||
|
||||
def tgz(files, links=()):
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
|
||||
for name, body in files.items():
|
||||
ti = tarfile.TarInfo(name)
|
||||
ti.size = len(body)
|
||||
tf.addfile(ti, io.BytesIO(body))
|
||||
for name, target in links:
|
||||
ti = tarfile.TarInfo(name)
|
||||
ti.type, ti.linkname = tarfile.SYMTYPE, target
|
||||
tf.addfile(ti)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def pages_dir():
|
||||
d = Path(tempfile.mkdtemp())
|
||||
(d / "ventures").mkdir()
|
||||
(d / "ventures" / "ventures.json").write_text(json.dumps(LEDGER, indent=1), encoding="utf-8")
|
||||
return d
|
||||
|
||||
|
||||
def refused(fn, *a, **kw):
|
||||
try:
|
||||
fn(*a, **kw)
|
||||
except seed.SeedError as e:
|
||||
return str(e)
|
||||
raise AssertionError("expected SeedError")
|
||||
|
||||
|
||||
def sha(b):
|
||||
return hashlib.sha256(b).hexdigest()
|
||||
|
||||
|
||||
def test_lands_and_lists():
|
||||
p = pages_dir()
|
||||
b = tgz({"hh/index.html": b"<!doctype html>hi", "hh/plates/a.svg": b"<svg/>", "hh/shots/desktop.png": b"png"})
|
||||
assert seed.land(p, "hh", b, sha(b), "House Hand", "A drawing hand.", today="2026-10-15") == sha(b)
|
||||
assert (p / "ventures/hh/plates/a.svg").read_bytes() == b"<svg/>"
|
||||
text = (p / "ventures/ventures.json").read_text()
|
||||
assert text == json.dumps(json.loads(text), indent=1) # same layout as the file it came from
|
||||
assert "\\u2014" in text # ASCII escapes kept, like the original
|
||||
first = json.loads(text)["ventures"][0]
|
||||
assert first["slug"] == "hh" and first["launched"] == "2026-10-15" and first["shot"] == "hh/shots/desktop.png"
|
||||
assert first["url"] == "https://hh.yolopreneur.lol/" and json.loads(text)["refreshed"] == "2026-10-14"
|
||||
|
||||
|
||||
def test_replaces_and_keeps_the_ledger_line():
|
||||
p = pages_dir()
|
||||
b1 = tgz({"hh/index.html": b"one", "hh/old.txt": b"stale"})
|
||||
seed.land(p, "hh", b1, sha(b1), "House Hand", "First line.", today="2026-10-15")
|
||||
b2 = tgz({"hh/index.html": b"two"})
|
||||
seed.land(p, "hh", b2, sha(b2), today="2026-10-20") # no name or line given: keep them
|
||||
assert not (p / "ventures/hh/old.txt").exists() and (p / "ventures/hh/index.html").read_bytes() == b"two"
|
||||
vs = [v for v in json.loads((p / "ventures/ventures.json").read_text())["ventures"] if v["slug"] == "hh"]
|
||||
assert len(vs) == 1 and vs[0]["one_liner"] == "First line." and vs[0]["launched"] == "2026-10-15"
|
||||
|
||||
|
||||
def test_refuses():
|
||||
p = pages_dir()
|
||||
good = tgz({"hh/index.html": b"x"})
|
||||
assert "mismatch" in refused(seed.land, p, "hh", good, "0" * 64, "n", "o")
|
||||
assert "bad slug" in refused(seed.land, p, "../etc", good, sha(good), "n", "o")
|
||||
assert "bad slug" in refused(seed.land, p, "ventures.json", good, sha(good), "n", "o")
|
||||
for bad, why in ((tgz({"hh/../../x": b"x", "hh/index.html": b"x"}), "unsafe"),
|
||||
(tgz({"hh/index.html": b"x"}, links=[("hh/link", "/etc/passwd")]), "plain file"),
|
||||
(tgz({"other/index.html": b"x"}), "under hh/"),
|
||||
(tgz({"hh/readme.txt": b"x"}), "no hh/index.html")):
|
||||
assert why in refused(seed.land, p, "hh", bad, sha(bad), "n", "o"), why
|
||||
assert "needs a name" in refused(seed.land, p, "hh", good, sha(good))
|
||||
assert not (p / "ventures/hh").exists() # nothing half-landed
|
||||
assert "https" in refused(seed.fetch, ["http://example.com/x.tgz"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
||||
for t in tests:
|
||||
t()
|
||||
print("ok", t.__name__)
|
||||
print(f"{len(tests)} seed tests passed")
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,34 @@
|
||||
name: astra
|
||||
# The slow loop: once a day, one planning call (TIME_ASTRA_SEAT, default `opus`) reads the board and the ledger and
|
||||
# labels a few cards `ready` for the heartbeat. It writes its reasoning to the "Astra log" issue; reply there to steer it.
|
||||
# Guardrails are code: TIME_ASTRA_MAX_READY (2) / TIME_ASTRA_MAX_NEW (2) per day, nothing while an `andon` is open,
|
||||
# and the same spend caps as every other call. Off until TIME_ASTRA=on.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "13 13 * * *"
|
||||
workflow_dispatch:
|
||||
concurrency:
|
||||
group: astra
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
plan:
|
||||
if: vars.TIME_ASTRA == 'on'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
sparse-checkout: .github/time
|
||||
persist-credentials: false
|
||||
- run: python3 .github/time/astra.py tick
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
TIME_TOKEN: ${{ secrets.TIME_TOKEN }} # repo secret: Astra's issues and labels go out as the `time` bot
|
||||
VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }}
|
||||
TIME_ASTRA_SEAT: ${{ vars.TIME_ASTRA_SEAT }}
|
||||
TIME_ASTRA_MAX_READY: ${{ vars.TIME_ASTRA_MAX_READY }}
|
||||
TIME_ASTRA_MAX_NEW: ${{ vars.TIME_ASTRA_MAX_NEW }}
|
||||
TIME_SPINE: ${{ vars.TIME_SPINE }}
|
||||
TIME_DAILY_USD: ${{ vars.TIME_DAILY_USD }}
|
||||
TIME_MONTHLY_USD: ${{ vars.TIME_MONTHLY_USD }}
|
||||
@@ -4,6 +4,8 @@ on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore: ["time/ledger/**"] # Time appends its own spend ledger every run; that is not a code change
|
||||
workflow_dispatch: # Time can run CI on a worker's PR branch by hand
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
@@ -12,4 +14,8 @@ jobs:
|
||||
timeout-minutes: 3
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v4 # node 22+ has node:sqlite, which the passkey test runs on
|
||||
with:
|
||||
node-version: 22
|
||||
- run: python3 .github/time/test_time.py
|
||||
- run: python3 .github/time/test_seed.py
|
||||
@@ -1,5 +1,5 @@
|
||||
name: doctor
|
||||
# Manual check of Time's setup: label, settings, keys (never printed). Result in the run summary.
|
||||
# Manual check of Time's setup: label, settings, keys (never printed), today's spend. Result in the run summary.
|
||||
on: workflow_dispatch
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -18,8 +18,14 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
||||
VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
TIME_VENTURE_DOMAIN: ${{ vars.TIME_VENTURE_DOMAIN }}
|
||||
TIME_MODE: ${{ vars.TIME_MODE }}
|
||||
TIME_DAILY_CAP: ${{ vars.TIME_DAILY_CAP }}
|
||||
TIME_DAILY_USD: ${{ vars.TIME_DAILY_USD }}
|
||||
TIME_MONTHLY_USD: ${{ vars.TIME_MONTHLY_USD }}
|
||||
TIME_REVIEW: ${{ vars.TIME_REVIEW }}
|
||||
TIME_WORKER: ${{ vars.TIME_WORKER }}
|
||||
TIME_AUTOMERGE: ${{ vars.TIME_AUTOMERGE }}
|
||||
@@ -11,21 +11,16 @@ on:
|
||||
types: [submitted]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
permissions:
|
||||
contents: write # squash-merge approved, green PRs
|
||||
issues: write
|
||||
pull-requests: write
|
||||
checks: read
|
||||
statuses: write # a Venice worker's test run is recorded as the PR's check
|
||||
# (GitHub-style `permissions:` are ignored by Gitea; the Actions token has write access to this repo)
|
||||
concurrency:
|
||||
group: heartbeat
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
tick:
|
||||
# issues: only `ready`/`spec` labels. comments: only on PRs, only a reviewer verdict (Approve/Object).
|
||||
# issues: only `ready`/`spec` labels. comments: only on PRs, only a reviewer verdict (Approve/Object), never Time's own.
|
||||
if: >-
|
||||
(github.event_name != 'issues' || contains(fromJSON('["ready","spec"]'), github.event.label.name)) &&
|
||||
(github.event_name != 'issue_comment' || (github.event.issue.pull_request &&
|
||||
(github.event_name != 'issue_comment' || (github.event.issue.pull_request && github.event.comment.user.login != 'gitea-actions' &&
|
||||
(startsWith(github.event.comment.body, '**Approve') || startsWith(github.event.comment.body, 'Approve') ||
|
||||
startsWith(github.event.comment.body, '**Object') || startsWith(github.event.comment.body, 'Object'))))
|
||||
runs-on: ubuntu-latest
|
||||
@@ -34,13 +29,20 @@ jobs:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
sparse-checkout: .github/time
|
||||
persist-credentials: false # the worker's shell must not find the token in .git/config
|
||||
- run: python3 .github/time/time.py heartbeat
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
||||
VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} # user-level secret on teahubxyz (docs/decisions.md on the spine)
|
||||
TIME_TOKEN: ${{ secrets.TIME_TOKEN }} # repo secret: the `time` bot opens and merges PRs (Gitea denies that to the Actions identity)
|
||||
TIME_TRUSTED: ${{ vars.TIME_TRUSTED }} # org owners etc.; collaborators are trusted without being listed
|
||||
TIME_RUN_IMAGE: node:22-bookworm # the worker's shell runs in a throwaway container, not next to these secrets
|
||||
TIME_MODE: ${{ vars.TIME_MODE }}
|
||||
TIME_DAILY_CAP: ${{ vars.TIME_DAILY_CAP }}
|
||||
TIME_DAILY_USD: ${{ vars.TIME_DAILY_USD }} # spend governor: dollars per day (default 5) …
|
||||
TIME_MONTHLY_USD: ${{ vars.TIME_MONTHLY_USD }} # … and per month (default 100); ledger in time/ledger/
|
||||
TIME_CURSOR_MODEL: ${{ vars.TIME_CURSOR_MODEL }}
|
||||
TIME_REVIEW: ${{ vars.TIME_REVIEW }}
|
||||
TIME_STUCK_HOURS: ${{ vars.TIME_STUCK_HOURS }}
|
||||
@@ -51,3 +53,4 @@ jobs:
|
||||
TIME_PLANNER: ${{ vars.TIME_PLANNER }}
|
||||
TIME_WORKER: ${{ vars.TIME_WORKER }}
|
||||
TIME_WORKER_STEPS: ${{ vars.TIME_WORKER_STEPS }}
|
||||
TIME_WORKER_MAX_TOKENS: ${{ vars.TIME_WORKER_MAX_TOKENS }}
|
||||
@@ -0,0 +1,38 @@
|
||||
name: labs-aicharts
|
||||
# Deploy the AICharts demo Worker (aicharts.<venture domain>, one D1, Workers AI), then smoke-test it. Manual only.
|
||||
on: workflow_dispatch
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
sparse-checkout: .github/time
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- run: node .github/time/labs/aicharts/test_aicharts.mjs
|
||||
- id: deploy
|
||||
run: |
|
||||
set -o pipefail
|
||||
python3 .github/time/labs/aicharts/deploy.py | tee out.json
|
||||
echo "url=$(python3 -c 'import json; print(json.load(open("out.json"))["url"])')" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
TIME_VENTURE_DOMAIN: ${{ vars.TIME_VENTURE_DOMAIN }}
|
||||
- name: smoke
|
||||
env:
|
||||
u: ${{ steps.deploy.outputs.url }} # through env, never interpolated into the script
|
||||
run: |
|
||||
set -o pipefail
|
||||
for i in $(seq 1 18); do curl -fsS "${u}api/health" && break; sleep 10; done
|
||||
curl -fsS "${u}api/schema?dataset=orders" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert len(d["sample"])==5, d; print("schema ok")'
|
||||
curl -fsS -X POST "${u}api/spec" -H 'content-type: application/json' \
|
||||
-d '{"dataset":"orders","prompt":"total revenue by region"}' | tee spec.json
|
||||
echo
|
||||
echo "### AICharts: ${u}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo '```json' >> "$GITHUB_STEP_SUMMARY"; cat spec.json >> "$GITHUB_STEP_SUMMARY"; echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -14,12 +14,13 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
reply:
|
||||
# cheap filter before a runner spins up: a mention, not from a bot, and only from people with a say in this repo
|
||||
# (public repo: strangers can comment, but must not be able to spend the seats' model credits)
|
||||
# cheap filter before a runner spins up: a mention, and not Time's own comment (Gitea fires events for the Actions
|
||||
# token's writes). Who may spend the seats' credits is checked in time.py (collaborator permission); Gitea has no
|
||||
# author_association in the event.
|
||||
if: >-
|
||||
contains(github.event.comment.body || github.event.issue.body, '@') &&
|
||||
(github.event.comment.user.type || github.event.issue.user.type) != 'Bot' &&
|
||||
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association || github.event.issue.author_association)
|
||||
(github.event.comment.user.login || github.event.issue.user.login) != 'gitea-actions' &&
|
||||
(github.event.comment.user.login || github.event.issue.user.login) != 'time'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 8
|
||||
steps:
|
||||
@@ -30,3 +31,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
TIME_TRUSTED: ${{ vars.TIME_TRUSTED }} # org owners etc.; collaborators are trusted without being listed
|
||||
TIME_DAILY_USD: ${{ vars.TIME_DAILY_USD }}
|
||||
TIME_MONTHLY_USD: ${{ vars.TIME_MONTHLY_USD }}
|
||||
@@ -0,0 +1,95 @@
|
||||
name: refresh
|
||||
# Refresh a venture that lives on gh-pages and makes some of its own files (fonts, art, screenshots, generated
|
||||
# code): run ventures/<slug>/tools/build.sh on the runner, in place, then commit what changed to gh-pages in one
|
||||
# commit and ask Teahub pages to pull. Actions -> refresh -> Run workflow.
|
||||
# `from` takes the venture's folder from another branch (say, a rework in progress), so the new version is built and
|
||||
# lands whole instead of file by file. The build runs in a checkout without credentials; only ventures/<slug>/ comes
|
||||
# back. Chrome for screenshots: CHROME (Playwright's Chromium is installed when the runner has none), with
|
||||
# CHROME_FLAGS for running as root.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
slug:
|
||||
description: "The venture: ventures/<slug>/ on gh-pages, with a tools/build.sh"
|
||||
required: true
|
||||
type: string
|
||||
from:
|
||||
description: "Branch to take the venture's folder from (empty: gh-pages itself)"
|
||||
required: false
|
||||
type: string
|
||||
permissions:
|
||||
contents: write
|
||||
concurrency:
|
||||
group: venture # the venture loop and seed write gh-pages too: take turns
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
refresh:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: check the inputs
|
||||
env: # inputs reach the scripts through the environment, never interpolated into them
|
||||
SLUG: ${{ inputs.slug }}
|
||||
FROM: ${{ inputs.from }}
|
||||
run: |
|
||||
printf '%s' "$SLUG" | grep -Eqx '[a-z0-9]([a-z0-9-]{0,40}[a-z0-9])?' || { echo "::error::bad slug: $SLUG"; exit 1; }
|
||||
[ -z "$FROM" ] || printf '%s' "$FROM" | grep -Eqx '[A-Za-z0-9._/-]{1,100}' || { echo "::error::bad branch: $FROM"; exit 1; }
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ inputs.from || 'gh-pages' }}
|
||||
path: work
|
||||
fetch-depth: 1
|
||||
sparse-checkout: ventures/${{ inputs.slug }}
|
||||
persist-credentials: false # the build that runs next must not find a token in .git/config
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- name: find Chrome, or install Playwright's Chromium
|
||||
run: |
|
||||
set -eu
|
||||
for c in google-chrome google-chrome-stable chromium chromium-browser; do
|
||||
if command -v "$c" >/dev/null 2>&1; then
|
||||
echo "CHROME=$(command -v "$c")" >> "$GITHUB_ENV"; echo "using $(command -v "$c")"; exit 0
|
||||
fi
|
||||
done
|
||||
echo "no Chrome on this runner: installing Playwright's Chromium"
|
||||
npx -y playwright@1.55.0 install --with-deps chromium > /tmp/pw.log 2>&1 || { tail -20 /tmp/pw.log; exit 1; }
|
||||
c=$(find "$HOME/.cache/ms-playwright" -type f -name chrome -path '*chrome-linux*' | head -1)
|
||||
[ -n "$c" ] && [ -x "$c" ] || { echo "::error::no Chromium after the install"; exit 1; }
|
||||
echo "CHROME=$c" >> "$GITHUB_ENV"; echo "using $c"
|
||||
- name: build
|
||||
env:
|
||||
SLUG: ${{ inputs.slug }}
|
||||
CHROME_FLAGS: "--no-sandbox --disable-dev-shm-usage --disable-gpu"
|
||||
run: |
|
||||
set -eu
|
||||
[ -f "work/ventures/$SLUG/tools/build.sh" ] || { echo "::error::ventures/$SLUG has no tools/build.sh"; exit 1; }
|
||||
sh "work/ventures/$SLUG/tools/build.sh"
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: gh-pages
|
||||
path: pages
|
||||
fetch-depth: 1
|
||||
- name: commit what changed
|
||||
env:
|
||||
SLUG: ${{ inputs.slug }}
|
||||
FROM: ${{ inputs.from }}
|
||||
run: |
|
||||
set -eu
|
||||
[ -f "pages/ventures/ventures.json" ] || { echo "::error::no ledger on gh-pages"; exit 1; }
|
||||
rm -rf "pages/ventures/$SLUG"
|
||||
cp -R "work/ventures/$SLUG" "pages/ventures/$SLUG"
|
||||
find "pages/ventures/$SLUG" -name __pycache__ -type d -prune -exec rm -rf {} +
|
||||
cd pages
|
||||
git add --all "ventures/$SLUG"
|
||||
if git diff --cached --quiet; then echo "nothing changed in ventures/$SLUG"; exit 0; fi
|
||||
git diff --cached --stat | tail -25
|
||||
git -c user.name='github-actions[bot]' -c user.email='github-actions[bot]@users.noreply.teahub.xyz' \
|
||||
commit -q -m "ventures: refresh $SLUG${FROM:+ from $FROM}"
|
||||
if ! git push -q origin HEAD:gh-pages; then # gh-pages moved under us: replay this one commit on top
|
||||
git pull -q --rebase origin gh-pages
|
||||
git push -q origin HEAD:gh-pages
|
||||
fi
|
||||
echo "refreshed: ventures/$SLUG -> https://$SLUG.yolopreneur.lol/"
|
||||
- name: tell Teahub pages to pull
|
||||
run: curl -fsS -X POST "https://yolopreneur.lol/_hook/${GITHUB_REPOSITORY}" || echo "pages pulls by itself within five minutes"
|
||||
@@ -0,0 +1,59 @@
|
||||
name: seed
|
||||
# Seed a venture on demand: land a site that was built somewhere else (by hand, on the Mac, by another agent) on
|
||||
# yolopreneur.lol. Actions -> seed -> Run workflow, with the inputs below.
|
||||
# The bundle is a .tgz whose top-level folder is the slug. .github/time/seed.py checks it against its sha256, copies
|
||||
# it over ventures/<slug>/ on gh-pages in one commit, adds or refreshes the venture's line in ventures/ventures.json,
|
||||
# and the last step asks Teahub pages to pull, so <slug>.yolopreneur.lol serves it within the minute.
|
||||
# Nothing in the bundle runs: it is checked (plain files and folders under <slug>/, an index.html) and copied.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
slug:
|
||||
description: "Folder and subdomain: ventures/<slug>/ is served at <slug>.yolopreneur.lol"
|
||||
required: true
|
||||
type: string
|
||||
bundle:
|
||||
description: "https URL of the .tgz, or of its parts, space-separated and in order"
|
||||
required: true
|
||||
type: string
|
||||
sha256:
|
||||
description: "sha256 of the whole .tgz"
|
||||
required: true
|
||||
type: string
|
||||
name:
|
||||
description: "Name in the ledger (needed for a new venture)"
|
||||
required: false
|
||||
type: string
|
||||
one_liner:
|
||||
description: "One line in the ledger (needed for a new venture)"
|
||||
required: false
|
||||
type: string
|
||||
permissions:
|
||||
contents: write
|
||||
concurrency:
|
||||
group: venture # the venture loop writes gh-pages too: take turns
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
seed:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
sparse-checkout: .github/time
|
||||
path: engine
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: gh-pages
|
||||
path: pages
|
||||
fetch-depth: 1
|
||||
- run: python3 engine/.github/time/seed.py pages
|
||||
env: # inputs reach the script through the environment, never interpolated into it
|
||||
SLUG: ${{ inputs.slug }}
|
||||
BUNDLE: ${{ inputs.bundle }}
|
||||
SHA: ${{ inputs.sha256 }}
|
||||
NAME: ${{ inputs.name }}
|
||||
ONE_LINER: ${{ inputs.one_liner }}
|
||||
- name: tell Teahub pages to pull
|
||||
run: curl -fsS -X POST "https://yolopreneur.lol/_hook/${GITHUB_REPOSITORY}" || echo "pages pulls by itself within five minutes"
|
||||
@@ -24,12 +24,12 @@ jobs:
|
||||
- name: copy engine, open PR if changed
|
||||
run: |
|
||||
set -e
|
||||
for f in .github/time/time.py .github/time/venture.py .github/time/test_time.py .github/time/seats.json .claude/skills/time/SKILL.md; do
|
||||
for f in .github/time/time.py .github/time/venture.py .github/time/cloudflare.py .github/time/cloudflare/worker.js .github/time/cloudflare/time.js .github/time/cloudflare/test_worker.mjs .github/time/cloudflare/test_auth.mjs .github/time/test_time.py .github/time/seats.json .github/ISSUE_TEMPLATE/early-access.yml .claude/skills/time/SKILL.md; do
|
||||
mkdir -p "$(dirname "$f")"; cp "/tmp/up/$f" "$f"
|
||||
done
|
||||
python3 .github/time/test_time.py > /dev/null
|
||||
wf=$(for f in /tmp/up/.github/workflows/*.yml; do b=.github/workflows/$(basename "$f"); cmp -s "$f" "$b" || echo "- \`$b\`"; done)
|
||||
if git diff --quiet; then
|
||||
if git diff --quiet && [ -z "$(git status --porcelain .github/time)" ]; then
|
||||
echo "Time engine is up to date." | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
[ -n "$wf" ] && printf 'Workflow files differ upstream, copy by hand:\n%s\n' "$wf" | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
@@ -37,6 +37,7 @@ jobs:
|
||||
sha=$(git -C /tmp/up rev-parse --short HEAD)
|
||||
branch="time-update-$sha"
|
||||
git switch -c "$branch"
|
||||
git add .github/time .github/ISSUE_TEMPLATE .claude/skills/time
|
||||
git -c user.name=time -c user.email=time@users.noreply.github.com commit -qam "Update Time to $UPSTREAM@$sha"
|
||||
git push -q origin "$branch"
|
||||
body="Fresh Time engine from \`$UPSTREAM@$CHANNEL\` ($sha). Offline tests pass."
|
||||
|
||||
@@ -1,31 +1,62 @@
|
||||
name: venture
|
||||
# The venture loop: one stage per tick. Daily kickoff plus a nudge every 20 minutes while a card is open
|
||||
# (cheap: a tick with no open card and TIME_VENTURE!=on exits at once). Off until TIME_VENTURE=on.
|
||||
# The venture loop: trending repos -> pitches -> board -> launch plan -> launch page on Pages -> 7 days of signups.
|
||||
# Every 20 minutes it advances whatever is due (cheap when nothing is); a new venture card starts once a day,
|
||||
# or right away: Actions -> venture -> Run workflow -> tick "Start a new venture now" (gh workflow run venture.yml -f start=true).
|
||||
# Batches: TIME_VENTURE_BATCH=3 (or Run workflow -> batch) runs k ventures at once in one milestone.
|
||||
# Off until TIME_VENTURE=on. Signups come in through .github/ISSUE_TEMPLATE/early-access.yml.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "*/20 * * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
start:
|
||||
description: "Start a new venture now, even if today's has already run"
|
||||
type: boolean
|
||||
default: false
|
||||
batch:
|
||||
description: "Start a venture batch now: one scout, the board's top k, launched together (k = TIME_VENTURE_BATCH, or 3)"
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pages: write
|
||||
statuses: write
|
||||
concurrency:
|
||||
group: venture
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
tick:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
sparse-checkout: .github/time
|
||||
persist-credentials: false # the worker's shell must not find the token in .git/config
|
||||
- run: python3 .github/time/venture.py tick
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
TIME_TOKEN: ${{ secrets.TIME_TOKEN }} # the `time` bot: opens and merges PRs (Gitea denies that to the Actions identity)
|
||||
TIME_RUN_IMAGE: node:22-bookworm # the worker's shell runs in a throwaway container, not next to these secrets
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} # optional: a backend per venture (cloudflare.py)
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
TIME_DAILY_USD: ${{ vars.TIME_DAILY_USD }}
|
||||
TIME_MONTHLY_USD: ${{ vars.TIME_MONTHLY_USD }}
|
||||
TIME_VENTURE_DOMAIN: ${{ vars.TIME_VENTURE_DOMAIN }}
|
||||
TIME_VENTURE_AI_MODEL: ${{ vars.TIME_VENTURE_AI_MODEL }}
|
||||
TIME_VENTURE: ${{ vars.TIME_VENTURE }}
|
||||
TIME_VENTURE_START: ${{ inputs.start && '1' || '' }}
|
||||
TIME_VENTURE_BATCH_START: ${{ inputs.batch && '1' || '' }}
|
||||
TIME_VENTURE_BATCH: ${{ vars.TIME_VENTURE_BATCH }} # k; set, a batch replaces the daily single card
|
||||
TIME_VENTURE_BATCH_HOLD_HOURS: ${{ vars.TIME_VENTURE_BATCH_HOLD_HOURS }} # launch-together hold (default 6)
|
||||
TIME_VENTURE_HEAVY: ${{ vars.TIME_VENTURE_HEAVY }} # worker runs at once per tick (default 3)
|
||||
TIME_VENTURE_PITCH: ${{ vars.TIME_VENTURE_PITCH }}
|
||||
TIME_VENTURE_BOARD: ${{ vars.TIME_VENTURE_BOARD }}
|
||||
TIME_VENTURE_CHAIR: ${{ vars.TIME_VENTURE_CHAIR }}
|
||||
TIME_VENTURE_MEASURE_DAYS: ${{ vars.TIME_VENTURE_MEASURE_DAYS }}
|
||||
TIME_WORKER: ${{ vars.TIME_WORKER }}
|
||||
TIME_WORKER_STEPS: ${{ vars.TIME_WORKER_STEPS }}
|
||||
TIME_WORKER_MAX_TOKENS: ${{ vars.TIME_WORKER_MAX_TOKENS }}
|
||||
@@ -2,5 +2,35 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Security
|
||||
- The worker's own commit status (`time/worker-run`) can fail or delay a merge but never earns one: with no other check on the PR head, Time dispatches the project's CI workflow on the PR branch (`TIME_CI_WORKFLOW`, default `ci.yml`, which must accept `workflow_dispatch`; GitHub skips `pull_request` runs for pushes made with the Actions token) and merges on that. `off` keeps the old "no checks" stop.
|
||||
- `.github/` is protected from auto-merge whatever `TIME_PROTECTED` says.
|
||||
- `labs-aicharts.yml` passes the deploy URL through `env` instead of interpolating it into the shell.
|
||||
- Time's state comments (`<!-- time {...} -->` beats) count only when written by `github-actions[bot]`; a pasted marker from anyone else is ignored. A stranger on a public repo could otherwise steer a card to merge a chosen PR or build a chosen page.
|
||||
- `/time retry` and "Approve"/"Object" comments count only from the owner, members, collaborators and Time itself; any other bot is a stranger too.
|
||||
- The protected-path check reads every file of a PR (not the first 100) and counts a rename under both names.
|
||||
- The browser pass clones the Pages branch, then drops `.git` (the clone URL carried the token), serves only the venture's own folder, and runs headless Chrome with no network except localhost, so a model-written page can't read or send anything.
|
||||
- The worker's `run` tool executes inside a throwaway container (`docker run`, the checkout mounted; the workflows set `TIME_RUN_IMAGE: node:22-bookworm`), so a command can't read the orchestrator's environment through `/proc`. Unset, or without a docker daemon, it is the host shell as before.
|
||||
- A venture build writes only inside its own `ventures/<slug>/` folder on the Pages branch, which also carries the studio's site and every other venture: a fix round once wrote its page over the site's front page.
|
||||
- The Venice worker's shell no longer sees secrets: its environment drops tokens and keys, its checkout keeps no token in `.git/config`, and the workflows check out without persisted credentials.
|
||||
|
||||
### Changed
|
||||
- The second fix round escalates to Kimi's code model (`venice:kimi code`) instead of grok 4.7: still a different model from the first round, at a fraction of the cost. `TIME_VENTURE_ESCALATE` overrides it.
|
||||
|
||||
### Added
|
||||
- Objections go back to the worker. `/time retry` on a card whose PR is open hands the worker the reviewer's latest comments (marked as the reviewer's, strangers' left out) on the same branch; `open_pr` on an open PR updates its description instead of failing; the card goes back to `dispatched`, so the next beat reviews again and an objection older than the push no longer counts.
|
||||
- A batch's children start in the tick that spawned them (GitHub lists a card made a moment ago only later, so they used to wait a tick).
|
||||
- Build briefs keep claims to what the code does: no promised features or schedules, and the trending repos are the wave a venture rides, never something it "builds on". Fix rounds take a failing claim off the page rather than building a feature in a hurry. In the first live batch, every fix round was a claim the code didn't back.
|
||||
- Venture batches (multi-ceremony): one batch card scouts once, the seats pitch a field (rounds until there are 2k pitches), and the board ranks the whole field; the top k, each wanted by two seats, become venture cards in the batch's own milestone. They run the ordinary loop side by side and launch together: a child that passes its checks waits at `ready` until every sibling is settled or the hold (`TIME_VENTURE_BATCH_HOLD_HOURS`, 6) runs out; stragglers then launch alone. No child left behind: a held child gets one batch retry, and held again it is counted dead with its reason. A retro on the batch card compares the cohort's signups against each plan's lines, then the card and milestone close. Set `TIME_VENTURE_BATCH=3` to make batches the daily default, or Actions → venture → Run workflow → "batch".
|
||||
- Parallel builds: a tick runs up to `TIME_VENTURE_HEAVY` (default 3) worker runs at once, one per card, so a batch builds in one tick instead of three. Each build keeps its own folder lock, `ventures.json` is updated under a lock with a fresh read, and a Pages write that loses the race for the branch head (409) is retried. `TIME_VENTURE_HEAVY=1` is the old one-at-a-time.
|
||||
- Fix rounds yield the worker runs to cards still due their first build, so a page that keeps failing can't starve the others.
|
||||
- Accounts for ventures, with passkeys: each venture's backend can sign people up and in (Face ID, Touch ID, Windows Hello, a phone), keep a 30-day session, and list what each person saved. No passwords, no email, nothing to configure; accounts live in the venture's own database on its own domain. `time.js` gains `Time.signUp`, `Time.signIn`, `Time.signOut`, `Time.user` and `Time.mine`, and builds are told to use them for the paid tier's promise without ever putting sign-in in front of the free product.
|
||||
- Venture backends use Gemma 4 (`@cf/google/gemma-4-26b-a4b-it`) by default and read both the older `{response}` and the OpenAI-style answer from Workers AI (the first default had been retired).
|
||||
- Ventures can be backed by Cloudflare: with `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`, each venture gets its own Worker, D1 database and Workers AI, and lives at `<slug>.<TIME_VENTURE_DOMAIN>` (or workers.dev) while its page stays on GitHub Pages. The page's `time.js` gives share links and a small model, and degrades to null so products still work without it. Existing ventures are backfilled a couple per tick; a new worker redeploys every venture; the doctor checks the token.
|
||||
- Venture loop launches products, not pages: a launch round where board seats write plans and one synthesizes `LAUNCH.md` with attribution; launch pages where the working product fills the first screen under a slim header with one call to action, then pricing and honest limits, in a look that follows the product; early-access signups through a GitHub issue form, thanked on their issues; deterministic and headless-Chrome checks with fix rounds and an andon; a 7-day decision against success and kill lines; a studio index at `/ventures/`.
|
||||
- Start a venture whenever you like: Actions → venture → Run workflow → "Start a new venture now" (`gh workflow run venture.yml -f start=true`); the schedule still starts one a day.
|
||||
- The board's taste: a delight anyone gets within a minute ranks above another developer utility, and when a product makes something people read (a deck, a card), its preloaded sample is the product's own pitch.
|
||||
- The studio index at `/ventures/` wears the Time site's blueprint theme (title block, grid paper, type), with each launched venture's laptop screenshot.
|
||||
- The headless-Chrome check fails a launch page where something sits on top of the product (a page fixed to the screen's height lets the next section slide over it).
|
||||
- The board sees the studio's own ventures: pitches that repeat one (by slug, name or an earlier slug) are set aside before the vote, since the week's trending list barely moves from day to day.
|
||||
- Hands-off upgrade support with spec planner, auto-merge, and Venice worker integration
|
||||
@@ -1,15 +1,25 @@
|
||||
# time
|
||||
|
||||
A GitHub-native agent factory. GitHub is the ledger, GitHub-hosted runners are the clock (Time), the worker is a Venice seat with hands or a Cursor cloud agent, and chat is where you plan.
|
||||
A Gitea-native agent factory, running on Teahub prod (teahub.xyz). The Gitea repo is the ledger, Gitea Actions are the clock (Time), the worker is a Claude or Venice seat with hands or a Cursor cloud agent, and chat is where you plan.
|
||||
|
||||
- **Cards**: an issue with a `DONE:` line, labelled `ready`.
|
||||
- **Heartbeat** (`heartbeat.yml`, hourly and whenever a card is labelled `ready`): moves cards forward. In `dry-run` (default) it only says what it would do; with `TIME_MODE=live` it dispatches each card to the worker (`TIME_WORKER`: a Venice seat such as `venice:kimi`, which edits the repo and opens the PR itself, or a Cursor cloud agent), which opens a PR that closes the card. An optional reviewer (`TIME_REVIEW`) checks the PR. At most `TIME_DAILY_CAP` launches a day.
|
||||
- **Mentions** (`mention.yml`): `@kimi`, `@grok 4.7`, `@qwen max high` in any issue, PR or comment gets that seat's reply (Venice).
|
||||
- **Heartbeat** (`heartbeat.yml`, hourly and whenever a card is labelled `ready`): moves cards forward. In `dry-run` (default) it only says what it would do; with `TIME_MODE=live` it dispatches each card to the worker (`TIME_WORKER`: a seat such as `claude:sonnet` or `venice:kimi`, which edits the repo and opens the PR itself, or a Cursor cloud agent), which opens a PR that closes the card. An optional reviewer (`TIME_REVIEW`) checks the PR. At most `TIME_DAILY_CAP` launches a day. **Spend governor:** every model call is booked to `time/ledger/<YYYY-MM>.jsonl` (tokens and dollars); once today's spend reaches `TIME_DAILY_USD` (default 5) or the month's reaches `TIME_MONTHLY_USD` (default 100), Time stops calling models, opens an `andon` issue, and a monthly breach also sets `TIME_MODE=dry-run`. No model decides whether to spend.
|
||||
- **Mentions** (`mention.yml`): `@sonnet`, `@opus`, `@kimi`, `@grok 4.7`, `@qwen max high` in any issue, PR or comment gets that seat's reply.
|
||||
- **Hands-off** (`TIME_AUTOMERGE=on`): label an issue `spec` and Time splits it into cards that run one at a time. A card's PR merges itself once the reviewer approves and checks are green, then the next card starts. Objections, red checks and protected paths (workflows, deploy, secrets) go to a human.
|
||||
- **Venture loop** (`venture.yml`, every 20 min once `TIME_VENTURE=on`): scouts the repos that gained the most stars this week, seats pitch one small static-page product each, the board votes (Borda), a seat builds it on `gh-pages`, and GitHub Pages ships it at `/ventures/<slug>/`. One card per venture (label `venture`), progress as comments.
|
||||
- **Doctor** (`doctor.yml`, manual): runs the tests and checks label, settings and keys.
|
||||
- **Venture loop** (`venture.yml`, every 20 min once `TIME_VENTURE=on`): one venture card a day, or one right away from Actions → venture → Run workflow → "Start a new venture now". It scouts the repos that gained the most stars this week; seats pitch; the board votes (Borda, quorum of two); every board seat writes a launch plan (positioning, first users, pricing, channels, 7-day success and kill lines) and one seat synthesizes them into `LAUNCH.md`; the worker builds the product as the page on `gh-pages` (the tool fills the first screen under a slim header with the one signup button; pricing, limits and the why sit below); deterministic checks and headless Chrome (phone and desktop) gate it, with two fix rounds before an andon; GitHub Pages ships it at `/ventures/<slug>/`. Signups go through an issue form (`.github/ISSUE_TEMPLATE/early-access.yml`), each one thanked on its issue; after 7 days the count of distinct people decides: build v2, kill, or watch a week more. The studio index lives at `/ventures/`. Launch posts are drafts for a human; nothing is posted outside the repo.
|
||||
- **Batches** (`TIME_VENTURE_BATCH=3`, or Run workflow → "batch"): one scout pass, a field of pitches, and the board's top k become venture cards in one milestone, built side by side and launched together. A held child gets one batch retry before it's counted dead; a retro compares the cohort once every child is measured.
|
||||
- **Seed** (`seed.yml`, manual): land a venture that was built somewhere else. Give it a slug, the URL of a .tgz whose top folder is that slug (or of its parts, in order) and the .tgz's sha256, plus a name and a one-liner for a new venture. `seed.py` checks the bundle, copies it to `ventures/<slug>/` on `gh-pages` in one commit, adds or refreshes its line in `ventures/ventures.json`, and asks Teahub pages to pull, so it is live at `<slug>.yolopreneur.lol` within the minute. Nothing in the bundle runs.
|
||||
- **Refresh** (`refresh.yml`, manual): for a venture that lives on gh-pages and makes some of its own files (fonts, art, screenshots, generated code). It runs `ventures/<slug>/tools/build.sh` on the runner, in place and without credentials, then commits what changed to gh-pages in one commit and asks Teahub pages to pull. `from` takes the folder from another branch first, so a reworked venture lands whole.
|
||||
- **Backends** (optional, `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID`): each venture also gets its own Cloudflare Worker, D1 database and Workers AI, serving its Pages folder at `<slug>.<TIME_VENTURE_DOMAIN>`. The page talks to it through `time.js` (share links that save a creation, a rate-limited small model, and accounts with passkeys: no passwords, no email); every call degrades gracefully, so products still work on Pages alone. Code: `.github/time/cloudflare.py` and `cloudflare/worker.js`.
|
||||
- **Doctor** (`doctor.yml`, manual): runs the tests and checks label, settings, keys and today's spend.
|
||||
- **Update** (`update.yml`, weekly or manual): opens a PR with the latest engine from teahubxyz/time, so every project stays on one Time instead of drifting copies.
|
||||
|
||||
## Setup
|
||||
|
||||
Open this repo in Claude Code or Cursor and say "set up Time". The agent follows `.claude/skills/time/SKILL.md`. You'll be asked to install Cursor's GitHub app on the repo and to paste your Cursor API key into `gh secret set CURSOR_API_KEY`.
|
||||
Started on GitHub as a proof of concept (teahubxyz/time, now read-only); ported to Gitea 2026-10-14.
|
||||
|
||||
Open this repo in Claude Code or Cursor and say "set up Time": the agent follows `.claude/skills/time/SKILL.md`. The recommended path needs one secret, `ANTHROPIC_API_KEY`: Claude seats plan, build and review (`TIME_WORKER=claude:sonnet`, `TIME_REVIEW=claude:haiku`; `haiku`, `sonnet`, `opus`, `fable` are the seats, priced in `.github/time/seats.json`). With `VENICE_API_KEY` instead, Venice seats do the same (`TIME_WORKER=venice:qwen coder`, `TIME_REVIEW=venice:grok`). A Cursor cloud agent can be the worker instead if you have a Cursor API key. The landing page walks through it: https://teahubxyz.github.io/time/
|
||||
|
||||
## Status
|
||||
|
||||
For changes and release history, see [CHANGELOG.md](CHANGELOG.md).
|
||||
@@ -1,258 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Time · an agent factory that lives in your GitHub repo</title>
|
||||
<meta name="description" content="Label an issue, get a merged PR. Time is a GitHub-native agent factory: issues are the ledger, GitHub Actions is the clock, and AI seats plan, build, review and merge.">
|
||||
<style>
|
||||
:root{
|
||||
--navy:#0c2340; --navy2:#0f2b4f; --line:#8fd3ff; --line2:rgba(143,211,255,.55); --faint:rgba(143,211,255,.16);
|
||||
--paper:#eaf6ff; --mute:#9cb6cf; --amber:#ffc857; --red:#ff7a6b; --green:#8ee6a8;
|
||||
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;
|
||||
--sans:"Avenir Next","Avenir","Segoe UI","Helvetica Neue",Arial,sans-serif;
|
||||
--cond:"Avenir Next Condensed","Arial Narrow","Roboto Condensed","Helvetica Neue",Arial,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html{background:var(--navy)}
|
||||
body{margin:0;color:var(--paper);font:16px/1.6 var(--sans);
|
||||
background:
|
||||
linear-gradient(var(--faint) 1px,transparent 1px) 0 0/24px 24px,
|
||||
linear-gradient(90deg,var(--faint) 1px,transparent 1px) 0 0/24px 24px,
|
||||
linear-gradient(rgba(143,211,255,.07) 1px,transparent 1px) 0 0/120px 120px,
|
||||
linear-gradient(90deg,rgba(143,211,255,.07) 1px,transparent 1px) 0 0/120px 120px,
|
||||
var(--navy)}
|
||||
a{color:var(--line);text-underline-offset:3px}
|
||||
a:hover{color:#fff}
|
||||
.wrap{max-width:1120px;margin:0 auto;padding:0 20px}
|
||||
.label{font:600 11px/1.2 var(--mono);letter-spacing:.16em;text-transform:uppercase;color:var(--line)}
|
||||
.sheet{border:1.5px solid var(--line2);position:relative}
|
||||
.sheet::before,.sheet::after{content:"";position:absolute;width:14px;height:14px;border:1.5px solid var(--line)}
|
||||
.sheet::before{top:-6px;left:-6px;border-right:0;border-bottom:0}
|
||||
.sheet::after{bottom:-6px;right:-6px;border-left:0;border-top:0}
|
||||
|
||||
/* title block */
|
||||
.top{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:18px 0;border-bottom:1.5px solid var(--line2)}
|
||||
.mark{display:flex;align-items:center;gap:10px;font:700 15px/1 var(--mono);letter-spacing:.08em;color:var(--paper);text-decoration:none}
|
||||
.mark svg{flex:none}
|
||||
.top nav{display:flex;gap:18px;font:13px/1 var(--mono)}
|
||||
.top nav a{color:var(--mute);text-decoration:none}.top nav a:hover{color:var(--paper)}
|
||||
|
||||
/* hero */
|
||||
.hero{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(0,.85fr);gap:40px;padding:56px 0 40px;align-items:center}
|
||||
h1{font:700 clamp(38px,5.6vw,66px)/.95 var(--cond);letter-spacing:-.01em;margin:14px 0 18px;text-transform:uppercase}
|
||||
h1 em{font-style:normal;color:var(--amber)}
|
||||
.lede{font-size:clamp(16px,2vw,19px);max-width:36em;color:#cfe6f8;margin:0 0 26px}
|
||||
.cta{display:inline-flex;align-items:center;gap:10px;background:var(--amber);color:#1a1300;font:700 16px/1 var(--sans);
|
||||
padding:16px 22px;border-radius:2px;text-decoration:none;box-shadow:4px 4px 0 var(--line)}
|
||||
.cta:hover{color:#1a1300;transform:translate(-1px,-1px);box-shadow:5px 5px 0 var(--line)}
|
||||
.ctas{display:flex;flex-wrap:wrap;align-items:center;gap:22px}
|
||||
.quiet{font:14px/1.4 var(--mono);color:var(--line)}
|
||||
.fine{font:12px/1.5 var(--mono);color:var(--mute);margin-top:16px}
|
||||
.callout{padding:18px 18px 16px;background:rgba(12,35,64,.82)}
|
||||
.callout pre{margin:10px 0 0;font:13px/1.65 var(--mono);color:var(--paper);white-space:pre-wrap;overflow-wrap:anywhere}
|
||||
.callout .c{color:var(--mute)}.callout .g{color:var(--green)}.callout .a{color:var(--amber)}
|
||||
|
||||
/* sections */
|
||||
section{padding:44px 0;border-top:1.5px solid var(--line2)}
|
||||
h2{font:700 clamp(28px,4.4vw,46px)/1 var(--cond);text-transform:uppercase;margin:8px 0 14px;letter-spacing:.005em}
|
||||
.sub{color:#cfe6f8;max-width:44em;margin:0 0 24px}
|
||||
.diagram{padding:12px;background:rgba(12,35,64,.7)}
|
||||
.diagram svg{display:block;width:100%;height:auto}
|
||||
.legend{display:flex;flex-wrap:wrap;gap:8px 22px;margin-top:12px;font:12px/1.4 var(--mono);color:var(--mute)}
|
||||
.legend b{color:var(--paper);font-weight:600}
|
||||
.grid3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:22px}
|
||||
.cell{padding:18px;background:rgba(12,35,64,.78)}
|
||||
.cell h3{font:700 20px/1.1 var(--cond);text-transform:uppercase;margin:6px 0 8px}
|
||||
.cell p{margin:0;color:#cfe6f8;font-size:15px}
|
||||
.receipts{list-style:none;padding:0;margin:0;display:grid;gap:10px}
|
||||
.receipts li{display:grid;grid-template-columns:7.5em minmax(0,1fr);gap:14px;padding:12px 14px;background:rgba(12,35,64,.78);border-left:3px solid var(--green)}
|
||||
.receipts .k{font:600 12px/1.6 var(--mono);color:var(--green);text-transform:uppercase;letter-spacing:.08em}
|
||||
.steps{counter-reset:s;list-style:none;padding:0;margin:0;display:grid;gap:16px}
|
||||
.steps li{counter-increment:s;display:grid;grid-template-columns:3.2em minmax(0,1fr);gap:14px;align-items:start}
|
||||
.steps li::before{content:counter(s,decimal-leading-zero);font:700 30px/1 var(--cond);color:var(--amber)}
|
||||
code,.code{font:13px/1.6 var(--mono);background:rgba(143,211,255,.1);padding:1px 6px;border-radius:2px;overflow-wrap:anywhere}
|
||||
.limits{columns:2;column-gap:36px;padding-left:18px;margin:0}
|
||||
.limits li{break-inside:avoid;margin:0 0 10px;color:#cfe6f8}
|
||||
.ventures{display:grid;gap:10px}
|
||||
.venture{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;padding:14px;background:rgba(12,35,64,.78);text-decoration:none;color:var(--paper)}
|
||||
.venture:hover{background:var(--navy2);color:#fff}
|
||||
.venture b{font:700 22px/1.1 var(--cond)}
|
||||
.venture span{display:block;color:var(--mute);font-size:14px}
|
||||
.tag{align-self:center;font:600 11px/1 var(--mono);letter-spacing:.12em;text-transform:uppercase;border:1.5px solid var(--line);padding:5px 8px;color:var(--line)}
|
||||
footer{padding:30px 0 60px;border-top:1.5px solid var(--line2);font:12px/1.7 var(--mono);color:var(--mute)}
|
||||
@media (max-width:860px){
|
||||
.hero{grid-template-columns:1fr;padding-top:36px}
|
||||
.grid3{grid-template-columns:1fr}
|
||||
.limits{columns:1}
|
||||
}
|
||||
@media (max-width:520px){
|
||||
.top nav{display:none}
|
||||
.receipts li{grid-template-columns:1fr;gap:4px}
|
||||
.cta{padding:15px 18px}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header class="top">
|
||||
<a class="mark" href="./" aria-label="Time home">
|
||||
<svg width="26" height="26" viewBox="0 0 26 26" aria-hidden="true"><circle cx="13" cy="13" r="11.5" fill="none" stroke="#8fd3ff" stroke-width="1.5"/><path d="M13 5v8l5 3" fill="none" stroke="#ffc857" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
TIME
|
||||
</a>
|
||||
<nav><a href="#loop">The loop</a><a href="#proof">Proof</a><a href="#start">Start</a><a href="ventures/">Ventures</a><a href="https://github.com/teahubxyz/time">GitHub</a></nav>
|
||||
</header>
|
||||
|
||||
<div class="hero">
|
||||
<div>
|
||||
<div class="label">Drawing 01 · a GitHub-native agent factory</div>
|
||||
<h1>Label an issue.<br><em>Get a merged PR.</em></h1>
|
||||
<p class="lede">Time lives in your repository. Issues are the ledger, GitHub Actions is the clock, and AI seats plan the work,
|
||||
write the code and review it; Time merges what passes. A human steps in only where it matters: objections, red checks, and the paths you protect.</p>
|
||||
<div class="ctas">
|
||||
<a class="cta" href="https://github.com/teahubxyz/time/generate" target="_blank" rel="noopener">Use this template →</a>
|
||||
<a class="quiet" href="#proof">See it work ↓</a>
|
||||
</div>
|
||||
<p class="fine">Free and open on GitHub. You bring a Venice API key for the seats; runners are GitHub's.</p>
|
||||
</div>
|
||||
<div class="callout sheet" aria-label="What a card looks like">
|
||||
<div class="label">Fig. 1 · card #7, as it ran</div>
|
||||
<pre><span class="c"># written by the planner from spec #5</span>
|
||||
Add a `## Status` section at the end
|
||||
of README.md that links to CHANGELOG.md
|
||||
|
||||
Part of #5 (step 2/2). Depends on #6.
|
||||
<span class="a">DONE:</span> README.md links to CHANGELOG.md
|
||||
|
||||
<span class="c"># the thread</span>
|
||||
<span class="g">⏱️</span> #6 merged: queued → ready
|
||||
<span class="g">⏱️</span> qwen is working on this now
|
||||
<span class="g">⏱️</span> qwen opened PR #10 (13 steps)
|
||||
<span class="g">✔</span> grok: Approve
|
||||
<span class="g">⏱️</span> approved and green, merged PR #10</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="loop">
|
||||
<div class="wrap">
|
||||
<div class="label">Drawing 02 · the loop</div>
|
||||
<h2>One spec in, merged cards out</h2>
|
||||
<p class="sub">Write a spec as an issue and label it <code>spec</code>. Time splits it into cards with dependencies, runs them one at a time,
|
||||
and pulls the next card the moment one merges. Every step is a comment on the card, so the ledger is the issue thread itself.</p>
|
||||
<div class="diagram sheet">
|
||||
<svg viewBox="0 0 1060 300" role="img" aria-label="Spec issue, planner, cards, worker opens a PR, reviewer approves, checks pass, auto-merge, next card; objections, red checks and protected paths go to a human.">
|
||||
<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10z" fill="#8fd3ff"/></marker>
|
||||
<marker id="am" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10z" fill="#ff7a6b"/></marker></defs>
|
||||
<g font-family="ui-monospace,Menlo,Consolas,monospace" font-size="13" fill="#eaf6ff">
|
||||
<g stroke="#8fd3ff" stroke-width="1.5" fill="rgba(12,35,64,.9)">
|
||||
<rect x="10" y="60" width="120" height="64"/><rect x="170" y="60" width="120" height="64"/><rect x="330" y="60" width="130" height="64"/>
|
||||
<rect x="500" y="60" width="130" height="64"/><rect x="670" y="60" width="120" height="64"/><rect x="830" y="60" width="120" height="64"/>
|
||||
</g>
|
||||
<text x="70" y="88" text-anchor="middle">spec issue</text><text x="70" y="108" text-anchor="middle" fill="#9cb6cf" font-size="11">label: spec</text>
|
||||
<text x="230" y="88" text-anchor="middle">planner</text><text x="230" y="108" text-anchor="middle" fill="#9cb6cf" font-size="11">cards + deps</text>
|
||||
<text x="395" y="88" text-anchor="middle">worker</text><text x="395" y="108" text-anchor="middle" fill="#9cb6cf" font-size="11">a seat with hands</text>
|
||||
<text x="565" y="88" text-anchor="middle">PR</text><text x="565" y="108" text-anchor="middle" fill="#9cb6cf" font-size="11">"Closes #N"</text>
|
||||
<text x="730" y="88" text-anchor="middle">reviewer</text><text x="730" y="108" text-anchor="middle" fill="#9cb6cf" font-size="11">Approve/Object</text>
|
||||
<text x="890" y="88" text-anchor="middle">auto-merge</text><text x="890" y="108" text-anchor="middle" fill="#9cb6cf" font-size="11">checks green</text>
|
||||
<g stroke="#8fd3ff" stroke-width="1.5" fill="none" marker-end="url(#ar)">
|
||||
<path d="M130 92H166"/><path d="M290 92H326"/><path d="M460 92H496"/><path d="M630 92H666"/><path d="M790 92H826"/>
|
||||
<path d="M950 92H1000V200H395V128"/>
|
||||
</g>
|
||||
<text x="410" y="222" fill="#ffc857" font-size="12">merged → the next card starts</text>
|
||||
<g stroke="#ff7a6b" stroke-width="1.5" fill="none" stroke-dasharray="5 4" marker-end="url(#am)"><path d="M730 124V262H776"/></g>
|
||||
<rect x="780" y="244" width="166" height="36" fill="rgba(255,122,107,.12)" stroke="#ff7a6b" stroke-width="1.5"/>
|
||||
<text x="863" y="267" text-anchor="middle" fill="#ff7a6b">needs-human</text>
|
||||
<text x="722" y="262" text-anchor="end" fill="#9cb6cf" font-size="11">objection · red checks · protected paths</text>
|
||||
<g stroke="#8fd3ff" stroke-width="1" fill="none"><path d="M10 40H950"/><path d="M10 34v12M950 34v12"/></g>
|
||||
<text x="480" y="30" text-anchor="middle" fill="#8fd3ff" font-size="11">one Actions run per beat · every beat is a comment on the card</text>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="legend"><span><b>Clock</b> GitHub Actions: hourly, and right away on labels and reviews</span><span><b>Brains</b> Venice seats: Kimi, Grok, Qwen, DeepSeek, GLM, Gemini</span><span><b>Ledger</b> the issue thread</span></div>
|
||||
</div>
|
||||
<div class="grid3" style="margin-top:22px">
|
||||
<div class="cell sheet"><div class="label">Seats</div><h3>@mention a model</h3><p>Write <code>@grok</code>, <code>@kimi high</code> or <code>@qwen max</code> in any issue or PR and that seat answers in the thread. Only you and your collaborators can summon them.</p></div>
|
||||
<div class="cell sheet"><div class="label">Hands</div><h3>The worker codes</h3><p>A seat edits the repo on its own branch, runs the tests on a runner, and opens the PR itself. Workflow files and secrets stay out of its reach.</p></div>
|
||||
<div class="cell sheet"><div class="label">Updates</div><h3>One engine, many repos</h3><p>Every copy gets a weekly pull request with the latest engine from teahubxyz/time, the way a CMS updates itself, so projects don't drift.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="proof">
|
||||
<div class="wrap">
|
||||
<div class="label">Drawing 03 · receipts</div>
|
||||
<h2>Proof, not promises</h2>
|
||||
<p class="sub">This repo's own history, nothing staged and nothing smoothed over. Click through: every step is a public comment.</p>
|
||||
<ul class="receipts">
|
||||
<li><span class="k">Spec</span><span><a href="https://github.com/teahubxyz/time/issues/5">#5</a> was labelled <code>spec</code>; the planner split it into cards <a href="https://github.com/teahubxyz/time/issues/6">#6</a> and <a href="https://github.com/teahubxyz/time/issues/7">#7</a>, the second waiting on the first.</span></li>
|
||||
<li><span class="k">First card</span><span>#6 hit four setup problems on its way (a model without tool calling, Actions not yet allowed to open PRs, no CI check, a missing permission). Each was fixed in the engine or the settings, and the card was retried with <code>/time retry</code>. Then Qwen opened <a href="https://github.com/teahubxyz/time/pull/9">PR #9</a>, Grok approved it, and Time merged it.</span></li>
|
||||
<li><span class="k">Second card</span><span>#7 was promoted the moment #9 merged. Qwen opened <a href="https://github.com/teahubxyz/time/pull/10">PR #10</a> a minute later, Grok approved, and Time merged it and closed the card. No human comment, label or click on the card or its PR.</span></li>
|
||||
<li><span class="k">Ventures</span><span>The same machinery runs a small venture studio: it reads what's trending on GitHub, the seats pitch and vote, write a launch plan, build the page and measure signups. <a href="ventures/">See the ventures →</a></span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="start">
|
||||
<div class="wrap">
|
||||
<div class="label">Drawing 04 · start</div>
|
||||
<h2>Running in four steps</h2>
|
||||
<ol class="steps">
|
||||
<li><div><b>Use the template.</b> <a href="https://github.com/teahubxyz/time/generate" target="_blank" rel="noopener">Create your copy</a> of teahubxyz/time; it's yours, public or private.</div></li>
|
||||
<li><div><b>Give the seats a key.</b> <span class="code">gh secret set VENICE_API_KEY</span></div></li>
|
||||
<li><div><b>Turn it on.</b> <span class="code">gh variable set TIME_MODE --body live</span>, <span class="code">TIME_WORKER --body "venice:qwen coder"</span>, <span class="code">TIME_REVIEW --body venice:grok</span>, and <span class="code">TIME_AUTOMERGE --body on</span> when you're ready to let it merge.</div></li>
|
||||
<li><div><b>Write a spec.</b> Open an issue that lists the cards, each with a <code>DONE:</code> line, and label it <code>spec</code>. Watch the thread.</div></li>
|
||||
</ol>
|
||||
<p class="fine">Or open your copy in Claude Code and say "set up Time": the setup skill in <code>.claude/skills/time</code> walks through it, and the Doctor workflow checks labels, settings and keys.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="limits">
|
||||
<div class="wrap">
|
||||
<div class="label">Drawing 05 · tolerances</div>
|
||||
<h2>Honest limits</h2>
|
||||
<ul class="limits">
|
||||
<li>The seats are as good as the models behind them. Small, well-scoped cards with a clear <code>DONE:</code> line work best.</li>
|
||||
<li>You pay Venice for the seats. GitHub's runners are free for public repos and metered for private ones.</li>
|
||||
<li>GitHub won't let Actions edit workflow files, so changes there (and anything under paths you protect) wait for a human.</li>
|
||||
<li>Dry-run is the default and there is a daily cap; turning on auto-merge is your call, per repo.</li>
|
||||
<li>On a public repo, only you and your collaborators can trigger seats or count as a reviewer. Never attach a self-hosted runner to a public repo.</li>
|
||||
<li>It's young software: the engine changes weekly, and the update pull request tells you what changed.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="ventures">
|
||||
<div class="wrap">
|
||||
<div class="label">Drawing 06 · output</div>
|
||||
<h2>Latest ventures</h2>
|
||||
<p class="sub">Built and launched by the venture loop on this repo; each one decides its own future after seven days of signups.</p>
|
||||
<div class="ventures" id="vlist"><a class="venture" href="ventures/"><div><b>The studio index</b><span>Every venture, its status and its signups.</span></div><span class="tag">open</span></a></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<div class="wrap">Time · <a href="https://github.com/teahubxyz/time">github.com/teahubxyz/time</a> · this page is static and loads nothing from other sites; the venture list below the fold is read from this site's own ventures.json.</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var box=document.getElementById("vlist");
|
||||
fetch("ventures/ventures.json",{cache:"no-store"}).then(function(r){return r.ok?r.json():null}).then(function(d){
|
||||
if(!d||!d.ventures||!d.ventures.length)return;
|
||||
var vs=d.ventures.slice().sort(function(a,b){return (b.planned||"").localeCompare(a.planned||"")||(b.card||0)-(a.card||0)}).slice(0,4);
|
||||
box.innerHTML="";
|
||||
vs.forEach(function(v){
|
||||
var a=document.createElement("a");a.className="venture";a.href="ventures/"+encodeURIComponent(v.slug)+"/";
|
||||
var d1=document.createElement("div"),b=document.createElement("b"),s=document.createElement("span"),t=document.createElement("span");
|
||||
b.textContent=v.name||v.slug;s.textContent=(v.one_liner||"")+(v.signups?" · "+v.signups+" signups":"");
|
||||
t.className="tag";t.textContent=v.status||"building";
|
||||
d1.appendChild(b);d1.appendChild(s);a.appendChild(d1);a.appendChild(t);box.appendChild(a);
|
||||
});
|
||||
var all=document.createElement("a");all.className="venture";all.href="ventures/";
|
||||
all.innerHTML='<div><b>All ventures</b><span>The studio index, with status and signups.</span></div><span class="tag">index</span>';
|
||||
box.appendChild(all);
|
||||
}).catch(function(){});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,195 @@
|
||||
{"at":"2026-10-14T19:52:34Z","model":"claude-sonnet-5-5","in":1404,"out":71,"usd":0.003518}
|
||||
{"at":"2026-10-14T19:55:22Z","model":"claude-sonnet-5-5","in":1404,"out":71,"usd":0.003518}
|
||||
{"at":"2026-10-14T19:55:24Z","model":"claude-sonnet-5-5","in":1957,"out":196,"usd":0.005874}
|
||||
{"at":"2026-10-14T19:55:25Z","model":"claude-sonnet-5-5","in":5086,"out":61,"usd":0.010782}
|
||||
{"at":"2026-10-14T19:55:31Z","model":"claude-sonnet-5-5","in":6918,"out":790,"usd":0.021736}
|
||||
{"at":"2026-10-14T19:55:34Z","model":"claude-sonnet-5-5","in":7932,"out":230,"usd":0.018164}
|
||||
{"at":"2026-10-14T19:55:36Z","model":"claude-sonnet-5-5","in":8729,"out":171,"usd":0.019168}
|
||||
{"at":"2026-10-14T19:55:41Z","model":"claude-sonnet-5-5","in":8902,"out":497,"usd":0.022774}
|
||||
{"at":"2026-10-14T20:24:06Z","model":"claude-sonnet-5-5","in":1404,"out":71,"usd":0.003518}
|
||||
{"at":"2026-10-14T20:24:08Z","model":"claude-sonnet-5-5","in":1972,"out":173,"usd":0.005674}
|
||||
{"at":"2026-10-14T20:24:10Z","model":"claude-sonnet-5-5","in":4228,"out":137,"usd":0.009826}
|
||||
{"at":"2026-10-14T20:24:11Z","model":"claude-sonnet-5-5","in":5142,"out":121,"usd":0.011494}
|
||||
{"at":"2026-10-14T20:24:13Z","model":"claude-sonnet-5-5","in":5270,"out":81,"usd":0.01135}
|
||||
{"at":"2026-10-14T20:24:14Z","model":"claude-sonnet-5-5","in":5477,"out":135,"usd":0.012304}
|
||||
{"at":"2026-10-14T20:24:16Z","model":"claude-sonnet-5-5","in":6192,"out":105,"usd":0.013434}
|
||||
{"at":"2026-10-14T20:24:18Z","model":"claude-sonnet-5-5","in":6504,"out":245,"usd":0.015458}
|
||||
{"at":"2026-10-14T20:24:22Z","model":"claude-sonnet-5-5","in":7887,"out":475,"usd":0.020524}
|
||||
{"at":"2026-10-14T20:24:30Z","model":"claude-sonnet-5-5","in":8409,"out":844,"usd":0.025258}
|
||||
{"at":"2026-10-14T20:26:11Z","model":"claude-haiku-5-5","in":1660,"out":1406,"usd":0.000869}
|
||||
{"at":"2026-10-14T21:03:34Z","model":"claude-sonnet-5-5","in":1541,"out":79,"usd":0.003872}
|
||||
{"at":"2026-10-14T21:03:42Z","model":"claude-sonnet-5-5","in":29380,"out":970,"usd":0.06846}
|
||||
{"at":"2026-10-14T21:03:48Z","model":"claude-sonnet-5-5","in":30236,"out":267,"usd":0.063142}
|
||||
{"at":"2026-10-14T21:03:53Z","model":"claude-sonnet-5-5","in":30731,"out":639,"usd":0.067852}
|
||||
{"at":"2026-10-14T21:08:04Z","model":"claude-haiku-5-5","in":1532,"out":1201,"usd":0.000754}
|
||||
{"at":"2026-10-14T21:18:09Z","model":"claude-sonnet-5-5","in":1449,"out":26,"usd":0.003158}
|
||||
{"at":"2026-10-14T21:18:11Z","model":"claude-sonnet-5-5","in":1928,"out":100,"usd":0.004856}
|
||||
{"at":"2026-10-14T21:18:13Z","model":"claude-sonnet-5-5","in":2470,"out":107,"usd":0.00601}
|
||||
{"at":"2026-10-14T21:18:15Z","model":"claude-sonnet-5-5","in":5197,"out":151,"usd":0.011904}
|
||||
{"at":"2026-10-14T21:18:17Z","model":"claude-sonnet-5-5","in":8086,"out":211,"usd":0.018282}
|
||||
{"at":"2026-10-14T21:18:19Z","model":"claude-sonnet-5-5","in":10670,"out":136,"usd":0.0227}
|
||||
{"at":"2026-10-14T21:18:24Z","model":"claude-sonnet-5-5","in":11881,"out":438,"usd":0.028142}
|
||||
{"at":"2026-10-14T21:18:35Z","model":"claude-sonnet-5-5","in":13013,"out":1796,"usd":0.043986}
|
||||
{"at":"2026-10-14T21:18:44Z","model":"claude-sonnet-5-5","in":14752,"out":1511,"usd":0.044614}
|
||||
{"at":"2026-10-14T21:18:51Z","model":"claude-sonnet-5-5","in":16199,"out":460,"usd":0.036998}
|
||||
{"at":"2026-10-14T21:22:16Z","model":"claude-opus-5-5","in":8564,"out":713,"usd":0.048516}
|
||||
{"at":"2026-10-14T21:23:11Z","model":"claude-haiku-5-5","in":2858,"out":1705,"usd":0.001138}
|
||||
{"at":"2026-10-14T21:39:57Z","model":"claude-sonnet-5-5","in":1527,"out":26,"usd":0.003314}
|
||||
{"at":"2026-10-14T21:39:59Z","model":"claude-sonnet-5-5","in":2006,"out":94,"usd":0.004952}
|
||||
{"at":"2026-10-14T21:40:00Z","model":"claude-sonnet-5-5","in":4528,"out":54,"usd":0.009596}
|
||||
{"at":"2026-10-14T21:40:03Z","model":"claude-sonnet-5-5","in":28923,"out":182,"usd":0.059666}
|
||||
{"at":"2026-10-14T21:40:06Z","model":"claude-sonnet-5-5","in":30029,"out":254,"usd":0.062598}
|
||||
{"at":"2026-10-14T21:40:09Z","model":"claude-sonnet-5-5","in":33924,"out":187,"usd":0.069718}
|
||||
{"at":"2026-10-14T21:40:12Z","model":"claude-sonnet-5-5","in":39941,"out":108,"usd":0.080962}
|
||||
{"at":"2026-10-14T21:40:15Z","model":"claude-sonnet-5-5","in":42992,"out":246,"usd":0.088444}
|
||||
{"at":"2026-10-14T21:40:17Z","model":"claude-sonnet-5-5","in":46955,"out":146,"usd":0.09537}
|
||||
{"at":"2026-10-14T21:41:13Z","model":"claude-sonnet-5-5","in":49497,"out":6680,"usd":0.165794}
|
||||
{"at":"2026-10-14T21:42:37Z","model":"claude-sonnet-5-5","in":50026,"out":10161,"usd":0.201662}
|
||||
{"at":"2026-10-14T21:44:19Z","model":"claude-sonnet-5-5","in":51291,"out":13054,"usd":0.233122}
|
||||
{"at":"2026-10-14T21:45:26Z","model":"claude-sonnet-5-5","in":54098,"out":9726,"usd":0.205456}
|
||||
{"at":"2026-10-14T21:45:34Z","model":"claude-sonnet-5-5","in":59375,"out":708,"usd":0.12583}
|
||||
{"at":"2026-10-14T21:46:19Z","model":"claude-sonnet-5-5","in":59860,"out":6021,"usd":0.17993}
|
||||
{"at":"2026-10-14T21:46:23Z","model":"claude-sonnet-5-5","in":63582,"out":383,"usd":0.130994}
|
||||
{"at":"2026-10-14T21:46:31Z","model":"claude-sonnet-5-5","in":64284,"out":690,"usd":0.135468}
|
||||
{"at":"2026-10-14T21:46:37Z","model":"claude-sonnet-5-5","in":64859,"out":488,"usd":0.134598}
|
||||
{"at":"2026-10-14T21:46:45Z","model":"claude-sonnet-5-5","in":65253,"out":650,"usd":0.137006}
|
||||
{"at":"2026-10-14T21:46:52Z","model":"claude-sonnet-5-5","in":65768,"out":637,"usd":0.137906}
|
||||
{"at":"2026-10-14T22:08:11Z","model":"claude-haiku-5-5","in":11278,"out":2444,"usd":0.00235}
|
||||
{"at":"2026-10-15T13:13:31Z","model":"claude-opus-5-5","in":23639,"out":614,"usd":0.106836}
|
||||
{"at":"2026-10-15T21:07:27Z","model":"claude-sonnet-5-5","in":1824,"out":26,"usd":0.003908}
|
||||
{"at":"2026-10-15T21:07:30Z","model":"claude-sonnet-5-5","in":2303,"out":172,"usd":0.006326}
|
||||
{"at":"2026-10-15T21:07:32Z","model":"claude-sonnet-5-5","in":2899,"out":154,"usd":0.007338}
|
||||
{"at":"2026-10-15T21:07:35Z","model":"claude-sonnet-5-5","in":5409,"out":257,"usd":0.013388}
|
||||
{"at":"2026-10-15T21:07:38Z","model":"claude-sonnet-5-5","in":7805,"out":223,"usd":0.01784}
|
||||
{"at":"2026-10-15T21:07:41Z","model":"claude-sonnet-5-5","in":10371,"out":169,"usd":0.022432}
|
||||
{"at":"2026-10-15T21:07:46Z","model":"claude-sonnet-5-5","in":11385,"out":395,"usd":0.02672}
|
||||
{"at":"2026-10-15T21:07:50Z","model":"claude-sonnet-5-5","in":13978,"out":300,"usd":0.030956}
|
||||
{"at":"2026-10-15T21:07:57Z","model":"claude-sonnet-5-5","in":16724,"out":661,"usd":0.040058}
|
||||
{"at":"2026-10-15T21:08:04Z","model":"claude-sonnet-5-5","in":17799,"out":493,"usd":0.040528}
|
||||
{"at":"2026-10-15T21:08:09Z","model":"claude-sonnet-5-5","in":18178,"out":229,"usd":0.038646}
|
||||
{"at":"2026-10-15T21:08:12Z","model":"claude-sonnet-5-5","in":19392,"out":121,"usd":0.039994}
|
||||
{"at":"2026-10-15T21:08:16Z","model":"claude-sonnet-5-5","in":19681,"out":279,"usd":0.042152}
|
||||
{"at":"2026-10-15T21:08:22Z","model":"claude-sonnet-5-5","in":20505,"out":168,"usd":0.04269}
|
||||
{"at":"2026-10-15T21:08:29Z","model":"claude-sonnet-5-5","in":22991,"out":158,"usd":0.047562}
|
||||
{"at":"2026-10-15T21:08:34Z","model":"claude-sonnet-5-5","in":25613,"out":290,"usd":0.054126}
|
||||
{"at":"2026-10-15T21:08:41Z","model":"claude-sonnet-5-5","in":29778,"out":279,"usd":0.062346}
|
||||
{"at":"2026-10-15T21:08:48Z","model":"claude-sonnet-5-5","in":31149,"out":220,"usd":0.064498}
|
||||
{"at":"2026-10-15T21:08:55Z","model":"claude-sonnet-5-5","in":31813,"out":316,"usd":0.066786}
|
||||
{"at":"2026-10-15T21:09:04Z","model":"claude-sonnet-5-5","in":33214,"out":175,"usd":0.068178}
|
||||
{"at":"2026-10-15T21:09:11Z","model":"claude-sonnet-5-5","in":35083,"out":239,"usd":0.072556}
|
||||
{"at":"2026-10-15T21:09:25Z","model":"claude-sonnet-5-5","in":36773,"out":1171,"usd":0.085256}
|
||||
{"at":"2026-10-15T21:09:33Z","model":"claude-sonnet-5-5","in":38114,"out":680,"usd":0.083028}
|
||||
{"at":"2026-10-15T21:09:43Z","model":"claude-sonnet-5-5","in":40762,"out":520,"usd":0.086724}
|
||||
{"at":"2026-10-15T21:09:47Z","model":"claude-sonnet-5-5","in":43289,"out":163,"usd":0.088208}
|
||||
{"at":"2026-10-15T21:09:54Z","model":"claude-sonnet-5-5","in":45917,"out":617,"usd":0.098004}
|
||||
{"at":"2026-10-15T21:10:10Z","model":"claude-sonnet-5-5","in":47228,"out":1790,"usd":0.112356}
|
||||
{"at":"2026-10-15T21:10:14Z","model":"claude-sonnet-5-5","in":48839,"out":100,"usd":0.098678}
|
||||
{"at":"2026-10-15T21:10:18Z","model":"claude-sonnet-5-5","in":51169,"out":364,"usd":0.105978}
|
||||
{"at":"2026-10-15T21:10:22Z","model":"claude-sonnet-5-5","in":52631,"out":115,"usd":0.106412}
|
||||
{"at":"2026-10-15T21:11:02Z","model":"claude-sonnet-5-5","in":53793,"out":4582,"usd":0.153406}
|
||||
{"at":"2026-10-15T21:11:14Z","model":"claude-sonnet-5-5","in":56077,"out":1296,"usd":0.125114}
|
||||
{"at":"2026-10-15T21:12:05Z","model":"claude-sonnet-5-5","in":56783,"out":3167,"usd":0.145236}
|
||||
{"at":"2026-10-15T21:12:22Z","model":"claude-sonnet-5-5","in":59396,"out":1933,"usd":0.138122}
|
||||
{"at":"2026-10-15T21:13:04Z","model":"claude-sonnet-5-5","in":59720,"out":3191,"usd":0.15135}
|
||||
{"at":"2026-10-15T21:13:29Z","model":"claude-sonnet-5-5","in":60806,"out":2548,"usd":0.147092}
|
||||
{"at":"2026-10-15T21:14:48Z","model":"claude-sonnet-5-5","in":61169,"out":3172,"usd":0.154058}
|
||||
{"at":"2026-10-15T21:15:32Z","model":"claude-sonnet-5-5","in":61526,"out":294,"usd":0.125992}
|
||||
{"at":"2026-10-15T21:16:46Z","model":"claude-sonnet-5-5","in":63016,"out":3974,"usd":0.165772}
|
||||
{"at":"2026-10-15T21:17:56Z","model":"claude-sonnet-5-5","in":64261,"out":8283,"usd":0.211352}
|
||||
{"at":"2026-10-15T21:18:27Z","model":"claude-sonnet-5-5","in":1910,"out":26,"usd":0.00408}
|
||||
{"at":"2026-10-15T21:18:32Z","model":"claude-sonnet-5-5","in":2389,"out":127,"usd":0.006048}
|
||||
{"at":"2026-10-15T21:18:37Z","model":"claude-sonnet-5-5","in":2820,"out":178,"usd":0.00742}
|
||||
{"at":"2026-10-15T21:18:43Z","model":"claude-sonnet-5-5","in":3412,"out":200,"usd":0.008824}
|
||||
{"at":"2026-10-15T21:18:49Z","model":"claude-sonnet-5-5","in":8239,"out":200,"usd":0.018478}
|
||||
{"at":"2026-10-15T21:19:15Z","model":"claude-sonnet-5-5","in":9837,"out":311,"usd":0.022784}
|
||||
{"at":"2026-10-15T21:19:21Z","model":"claude-sonnet-5-5","in":10098,"out":318,"usd":0.023376}
|
||||
{"at":"2026-10-15T21:19:48Z","model":"claude-sonnet-5-5","in":10435,"out":488,"usd":0.02575}
|
||||
{"at":"2026-10-15T21:20:27Z","model":"claude-sonnet-5-5","in":13110,"out":1545,"usd":0.04167}
|
||||
{"at":"2026-10-15T21:21:24Z","model":"claude-sonnet-5-5","in":18362,"out":1083,"usd":0.047554}
|
||||
{"at":"2026-10-15T21:22:03Z","model":"claude-sonnet-5-5","in":20427,"out":1811,"usd":0.058964}
|
||||
{"at":"2026-10-15T21:22:09Z","model":"claude-sonnet-5-5","in":22877,"out":174,"usd":0.047494}
|
||||
{"at":"2026-10-15T21:22:13Z","model":"claude-sonnet-5-5","in":23372,"out":117,"usd":0.047914}
|
||||
{"at":"2026-10-15T21:22:17Z","model":"claude-sonnet-5-5","in":25793,"out":120,"usd":0.052786}
|
||||
{"at":"2026-10-15T21:22:20Z","model":"claude-sonnet-5-5","in":27604,"out":135,"usd":0.056558}
|
||||
{"at":"2026-10-15T21:22:26Z","model":"claude-sonnet-5-5","in":30017,"out":195,"usd":0.061984}
|
||||
{"at":"2026-10-15T21:22:31Z","model":"claude-sonnet-5-5","in":33384,"out":172,"usd":0.068488}
|
||||
{"at":"2026-10-15T21:22:36Z","model":"claude-sonnet-5-5","in":35727,"out":234,"usd":0.073794}
|
||||
{"at":"2026-10-15T21:22:42Z","model":"claude-sonnet-5-5","in":38098,"out":235,"usd":0.078546}
|
||||
{"at":"2026-10-15T21:22:48Z","model":"claude-sonnet-5-5","in":39963,"out":223,"usd":0.082156}
|
||||
{"at":"2026-10-15T21:22:55Z","model":"claude-sonnet-5-5","in":41469,"out":389,"usd":0.086828}
|
||||
{"at":"2026-10-15T21:23:02Z","model":"claude-sonnet-5-5","in":43852,"out":563,"usd":0.093334}
|
||||
{"at":"2026-10-15T21:23:11Z","model":"claude-sonnet-5-5","in":45712,"out":299,"usd":0.094414}
|
||||
{"at":"2026-10-15T21:23:18Z","model":"claude-sonnet-5-5","in":48051,"out":359,"usd":0.099692}
|
||||
{"at":"2026-10-15T21:23:25Z","model":"claude-sonnet-5-5","in":49292,"out":591,"usd":0.104494}
|
||||
{"at":"2026-10-15T21:23:32Z","model":"claude-sonnet-5-5","in":49954,"out":535,"usd":0.105258}
|
||||
{"at":"2026-10-15T21:23:47Z","model":"claude-sonnet-5-5","in":50931,"out":1659,"usd":0.118452}
|
||||
{"at":"2026-10-15T21:24:16Z","model":"claude-sonnet-5-5","in":53002,"out":241,"usd":0.108414}
|
||||
{"at":"2026-10-15T21:25:18Z","model":"claude-sonnet-5-5","in":55414,"out":858,"usd":0.119408}
|
||||
{"at":"2026-10-16T00:07:24Z","model":"claude-sonnet-5-5","in":1748,"out":26,"usd":0.003756}
|
||||
{"at":"2026-10-16T00:07:26Z","model":"claude-sonnet-5-5","in":2262,"out":145,"usd":0.005974}
|
||||
{"at":"2026-10-16T00:07:31Z","model":"claude-sonnet-5-5","in":2801,"out":177,"usd":0.007372}
|
||||
{"at":"2026-10-16T00:07:33Z","model":"claude-sonnet-5-5","in":3066,"out":134,"usd":0.007472}
|
||||
{"at":"2026-10-16T00:07:35Z","model":"claude-sonnet-5-5","in":5714,"out":131,"usd":0.012738}
|
||||
{"at":"2026-10-16T00:07:38Z","model":"claude-sonnet-5-5","in":6240,"out":207,"usd":0.01455}
|
||||
{"at":"2026-10-16T00:07:41Z","model":"claude-sonnet-5-5","in":7994,"out":306,"usd":0.019048}
|
||||
{"at":"2026-10-16T00:07:45Z","model":"claude-sonnet-5-5","in":8511,"out":312,"usd":0.020142}
|
||||
{"at":"2026-10-16T00:07:48Z","model":"claude-sonnet-5-5","in":8726,"out":183,"usd":0.019282}
|
||||
{"at":"2026-10-16T00:07:52Z","model":"claude-sonnet-5-5","in":11089,"out":342,"usd":0.025598}
|
||||
{"at":"2026-10-16T00:07:57Z","model":"claude-sonnet-5-5","in":13674,"out":315,"usd":0.030498}
|
||||
{"at":"2026-10-16T00:08:00Z","model":"claude-sonnet-5-5","in":13998,"out":176,"usd":0.029756}
|
||||
{"at":"2026-10-16T00:08:03Z","model":"claude-sonnet-5-5","in":14657,"out":217,"usd":0.031484}
|
||||
{"at":"2026-10-16T00:08:06Z","model":"claude-sonnet-5-5","in":14836,"out":296,"usd":0.032632}
|
||||
{"at":"2026-10-16T00:08:09Z","model":"claude-sonnet-5-5","in":15055,"out":240,"usd":0.03251}
|
||||
{"at":"2026-10-16T00:08:13Z","model":"claude-sonnet-5-5","in":15556,"out":323,"usd":0.034342}
|
||||
{"at":"2026-10-16T00:08:19Z","model":"claude-sonnet-5-5","in":16047,"out":604,"usd":0.038134}
|
||||
{"at":"2026-10-16T00:08:24Z","model":"claude-sonnet-5-5","in":18731,"out":405,"usd":0.041512}
|
||||
{"at":"2026-10-16T00:08:27Z","model":"claude-sonnet-5-5","in":21495,"out":225,"usd":0.04524}
|
||||
{"at":"2026-10-16T00:08:31Z","model":"claude-sonnet-5-5","in":22853,"out":250,"usd":0.048206}
|
||||
{"at":"2026-10-16T00:08:34Z","model":"claude-sonnet-5-5","in":24396,"out":190,"usd":0.050692}
|
||||
{"at":"2026-10-16T00:08:41Z","model":"claude-sonnet-5-5","in":25335,"out":686,"usd":0.05753}
|
||||
{"at":"2026-10-16T00:08:50Z","model":"claude-sonnet-5-5","in":28112,"out":930,"usd":0.065524}
|
||||
{"at":"2026-10-16T00:08:57Z","model":"claude-sonnet-5-5","in":30633,"out":802,"usd":0.069286}
|
||||
{"at":"2026-10-16T00:09:02Z","model":"claude-sonnet-5-5","in":32302,"out":349,"usd":0.068094}
|
||||
{"at":"2026-10-16T00:09:05Z","model":"claude-sonnet-5-5","in":33274,"out":208,"usd":0.068628}
|
||||
{"at":"2026-10-16T00:09:11Z","model":"claude-sonnet-5-5","in":36049,"out":684,"usd":0.078938}
|
||||
{"at":"2026-10-16T00:09:14Z","model":"claude-sonnet-5-5","in":38622,"out":324,"usd":0.080484}
|
||||
{"at":"2026-10-16T00:09:18Z","model":"claude-sonnet-5-5","in":39575,"out":201,"usd":0.08116}
|
||||
{"at":"2026-10-16T00:09:23Z","model":"claude-sonnet-5-5","in":40835,"out":318,"usd":0.08485}
|
||||
{"at":"2026-10-16T00:09:26Z","model":"claude-sonnet-5-5","in":41086,"out":177,"usd":0.083942}
|
||||
{"at":"2026-10-16T00:09:29Z","model":"claude-sonnet-5-5","in":41232,"out":173,"usd":0.084194}
|
||||
{"at":"2026-10-16T00:09:32Z","model":"claude-sonnet-5-5","in":43542,"out":130,"usd":0.088384}
|
||||
{"at":"2026-10-16T00:09:34Z","model":"claude-sonnet-5-5","in":44461,"out":132,"usd":0.090242}
|
||||
{"at":"2026-10-16T00:09:48Z","model":"claude-sonnet-5-5","in":46675,"out":1681,"usd":0.11016}
|
||||
{"at":"2026-10-16T00:10:04Z","model":"claude-sonnet-5-5","in":47241,"out":1595,"usd":0.110432}
|
||||
{"at":"2026-10-16T00:10:25Z","model":"claude-sonnet-5-5","in":47413,"out":2384,"usd":0.118666}
|
||||
{"at":"2026-10-16T00:10:39Z","model":"claude-sonnet-5-5","in":47674,"out":1158,"usd":0.106928}
|
||||
{"at":"2026-10-16T00:11:05Z","model":"claude-sonnet-5-5","in":49438,"out":3150,"usd":0.130376}
|
||||
{"at":"2026-10-16T00:11:37Z","model":"claude-sonnet-5-5","in":51544,"out":3967,"usd":0.142758}
|
||||
{"at":"2026-10-16T13:13:33Z","model":"claude-opus-5-5","in":27046,"out":777,"usd":0.123724}
|
||||
{"at":"2026-10-17T13:13:44Z","model":"claude-opus-5-5","in":26444,"out":667,"usd":0.119116}
|
||||
{"at":"2026-10-17T18:18:07Z","model":"claude-sonnet-5-5","in":1751,"out":26,"usd":0.003762}
|
||||
{"at":"2026-10-17T18:18:09Z","model":"claude-sonnet-5-5","in":2279,"out":284,"usd":0.007398}
|
||||
{"at":"2026-10-17T18:18:13Z","model":"claude-sonnet-5-5","in":28494,"out":393,"usd":0.060918}
|
||||
{"at":"2026-10-17T18:18:16Z","model":"claude-sonnet-5-5","in":31262,"out":208,"usd":0.064604}
|
||||
{"at":"2026-10-17T18:18:42Z","model":"claude-sonnet-5-5","in":34213,"out":3118,"usd":0.099606}
|
||||
{"at":"2026-10-17T18:19:44Z","model":"claude-sonnet-5-5","in":35944,"out":7606,"usd":0.147948}
|
||||
{"at":"2026-10-17T18:20:25Z","model":"claude-sonnet-5-5","in":37180,"out":4821,"usd":0.12257}
|
||||
{"at":"2026-10-17T18:20:51Z","model":"claude-sonnet-5-5","in":38270,"out":3106,"usd":0.1076}
|
||||
{"at":"2026-10-17T18:22:14Z","model":"claude-sonnet-5-5","in":41295,"out":11757,"usd":0.20016}
|
||||
{"at":"2026-10-17T18:22:29Z","model":"claude-sonnet-5-5","in":45974,"out":1680,"usd":0.108748}
|
||||
{"at":"2026-10-17T18:23:09Z","model":"claude-sonnet-5-5","in":46382,"out":6616,"usd":0.158924}
|
||||
{"at":"2026-10-17T18:23:11Z","model":"claude-sonnet-5-5","in":51455,"out":72,"usd":0.10363}
|
||||
{"at":"2026-10-17T18:23:14Z","model":"claude-sonnet-5-5","in":51814,"out":351,"usd":0.107138}
|
||||
{"at":"2026-10-17T18:23:24Z","model":"claude-sonnet-5-5","in":52210,"out":1496,"usd":0.11938}
|
||||
{"at":"2026-10-17T18:23:31Z","model":"claude-sonnet-5-5","in":53661,"out":655,"usd":0.113872}
|
||||
{"at":"2026-10-17T18:23:43Z","model":"claude-sonnet-5-5","in":54212,"out":1549,"usd":0.123914}
|
||||
{"at":"2026-10-17T18:27:28Z","model":"claude-haiku-5-5","in":14087,"out":3022,"usd":0.00292}
|
||||
{"at":"2026-10-17T18:27:32Z","model":"claude-sonnet-5-5","in":1363,"out":26,"usd":0.002986}
|
||||
{"at":"2026-10-17T18:27:34Z","model":"claude-sonnet-5-5","in":1891,"out":100,"usd":0.004782}
|
||||
{"at":"2026-10-17T18:27:36Z","model":"claude-sonnet-5-5","in":3064,"out":115,"usd":0.007278}
|
||||
{"at":"2026-10-17T18:27:38Z","model":"claude-sonnet-5-5","in":5804,"out":98,"usd":0.012588}
|
||||
{"at":"2026-10-17T18:27:44Z","model":"claude-sonnet-5-5","in":7456,"out":857,"usd":0.023482}
|
||||
{"at":"2026-10-17T18:27:49Z","model":"claude-sonnet-5-5","in":8267,"out":820,"usd":0.024734}
|
||||
{"at":"2026-10-17T18:27:55Z","model":"claude-sonnet-5-5","in":9230,"out":918,"usd":0.02764}
|
||||
{"at":"2026-10-17T18:27:59Z","model":"claude-sonnet-5-5","in":10126,"out":226,"usd":0.022512}
|
||||
{"at":"2026-10-17T18:28:11Z","model":"claude-haiku-5-5","in":2565,"out":1767,"usd":0.00114}
|
||||
@@ -0,0 +1,30 @@
|
||||
# Spend ledger
|
||||
|
||||
Time keeps a record of what it spends on models. This folder holds it.
|
||||
|
||||
- **One file per month:** `time/ledger/<YYYY-MM>.jsonl`, for example `2026-10.jsonl`.
|
||||
- **One JSON line per model call.** Fields:
|
||||
- `at`: when the call was made, ISO 8601 in UTC (`2026-10-14T09:30:00Z`).
|
||||
- `model`: the model that was called.
|
||||
- `in`: input (prompt) tokens.
|
||||
- `out`: output (completion) tokens.
|
||||
- `usd`: the cost in US dollars, or `null` if the seat has no price (unpriced seats).
|
||||
|
||||
Example line:
|
||||
|
||||
```json
|
||||
{"at":"2026-10-14T09:30:00Z","model":"some-model","in":1200,"out":350,"usd":0.0123}
|
||||
```
|
||||
|
||||
## Who writes and reads it
|
||||
|
||||
- **Written by Time itself.** At the end of each run, Time appends that run's calls to the current month's file in one `ledger:` commit.
|
||||
- **Read before every model call.** Whichever workflow is about to call a model (heartbeat, mentions, venture loop, Astra) adds up `usd` for today and for the month and refuses the call if either total has reached its cap (`TIME_DAILY_USD`, `TIME_MONTHLY_USD`). A `null` counts as zero.
|
||||
|
||||
## When a cap trips
|
||||
|
||||
Time stops calling models. The next time the heartbeat has a card to run, it opens one issue labelled `andon` (never a second while that one is open). A monthly cap also switches `TIME_MODE` to `dry-run`, or says in the issue that it couldn't, until a human raises the cap. A daily cap clears itself at midnight UTC. Close the issue once it is handled.
|
||||
|
||||
## Don't edit it by hand
|
||||
|
||||
Humans should never edit these files. The caps rely on them being exact, and Time only ever appends. A hand edit can hide spend or break the next append.
|
||||
@@ -1,176 +0,0 @@
|
||||
var Eh=i=>{throw TypeError(i)};var Th=(i,r,s)=>r.has(i)||Eh("Cannot "+s);var mt=(i,r,s)=>(Th(i,r,"read from private field"),s?s.call(i):r.get(i)),_h=(i,r,s)=>r.has(i)?Eh("Cannot add the same private member more than once"):r instanceof WeakSet?r.add(i):r.set(i,s),hs=(i,r,s,o)=>(Th(i,r,"write to private field"),o?o.call(i,s):r.set(i,s),s);function cp(i,r){for(var s=0;s<r.length;s++){const o=r[s];if(typeof o!="string"&&!Array.isArray(o)){for(const d in o)if(d!=="default"&&!(d in i)){const m=Object.getOwnPropertyDescriptor(o,d);m&&Object.defineProperty(i,d,m.get?m:{enumerable:!0,get:()=>o[d]})}}}return Object.freeze(Object.defineProperty(i,Symbol.toStringTag,{value:"Module"}))}(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const d of document.querySelectorAll('link[rel="modulepreload"]'))o(d);new MutationObserver(d=>{for(const m of d)if(m.type==="childList")for(const g of m.addedNodes)g.tagName==="LINK"&&g.rel==="modulepreload"&&o(g)}).observe(document,{childList:!0,subtree:!0});function s(d){const m={};return d.integrity&&(m.integrity=d.integrity),d.referrerPolicy&&(m.referrerPolicy=d.referrerPolicy),d.crossOrigin==="use-credentials"?m.credentials="include":d.crossOrigin==="anonymous"?m.credentials="omit":m.credentials="same-origin",m}function o(d){if(d.ep)return;d.ep=!0;const m=s(d);fetch(d.href,m)}})();function op(i){return i&&i.__esModule&&Object.prototype.hasOwnProperty.call(i,"default")?i.default:i}var gs={exports:{}},se={};/**
|
||||
* @license React
|
||||
* react.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var Ch;function rp(){if(Ch)return se;Ch=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.portal"),s=Symbol.for("react.fragment"),o=Symbol.for("react.strict_mode"),d=Symbol.for("react.profiler"),m=Symbol.for("react.consumer"),g=Symbol.for("react.context"),S=Symbol.for("react.forward_ref"),O=Symbol.for("react.suspense"),x=Symbol.for("react.memo"),z=Symbol.for("react.lazy"),h=Symbol.for("react.activity"),R=Symbol.for("react.view_transition"),D=Symbol.iterator;function V(b){return b===null||typeof b!="object"?null:(b=D&&b[D]||b["@@iterator"],typeof b=="function"?b:null)}var Z={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Y=Object.assign,K={};function H(b,j,k){this.props=b,this.context=j,this.refs=K,this.updater=k||Z}H.prototype.isReactComponent={},H.prototype.setState=function(b,j){if(typeof b!="object"&&typeof b!="function"&&b!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,b,j,"setState")},H.prototype.forceUpdate=function(b){this.updater.enqueueForceUpdate(this,b,"forceUpdate")};function F(){}F.prototype=H.prototype;function ue(b,j,k){this.props=b,this.context=j,this.refs=K,this.updater=k||Z}var fe=ue.prototype=new F;fe.constructor=ue,Y(fe,H.prototype),fe.isPureReactComponent=!0;var ae=Array.isArray;function X(){}var B={H:null,A:null,T:null,S:null},L=Object.prototype.hasOwnProperty;function J(b,j,k){var ne=k.ref;return{$$typeof:i,type:b,key:j,ref:ne!==void 0?ne:null,props:k}}function de(b,j){return J(b.type,j,b.props)}function Re(b){return typeof b=="object"&&b!==null&&b.$$typeof===i}function He(b){var j={"=":"=0",":":"=2"};return"$"+b.replace(/[=:]/g,function(k){return j[k]})}var Xe=/\/+/g;function Ce(b,j){return typeof b=="object"&&b!==null&&b.key!=null?He(""+b.key):j.toString(36)}function q(b){switch(b.status){case"fulfilled":return b.value;case"rejected":throw b.reason;default:switch(typeof b.status=="string"?b.then(X,X):(b.status="pending",b.then(function(j){b.status==="pending"&&(b.status="fulfilled",b.value=j)},function(j){b.status==="pending"&&(b.status="rejected",b.reason=j)})),b.status){case"fulfilled":return b.value;case"rejected":throw b.reason}}throw b}function $(b,j,k,ne,Ee){var ce=typeof b;(ce==="undefined"||ce==="boolean")&&(b=null);var Ae=!1;if(b===null)Ae=!0;else switch(ce){case"bigint":case"string":case"number":Ae=!0;break;case"object":switch(b.$$typeof){case i:case r:Ae=!0;break;case z:return Ae=b._init,$(Ae(b._payload),j,k,ne,Ee)}}if(Ae)return Ee=Ee(b),Ae=ne===""?"."+Ce(b,0):ne,ae(Ee)?(k="",Ae!=null&&(k=Ae.replace(Xe,"$&/")+"/"),$(Ee,j,k,"",function(Nt){return Nt})):Ee!=null&&(Re(Ee)&&(Ee=de(Ee,k+(Ee.key==null||b&&b.key===Ee.key?"":(""+Ee.key).replace(Xe,"$&/")+"/")+Ae)),j.push(Ee)),1;Ae=0;var ee=ne===""?".":ne+":";if(ae(b))for(var oe=0;oe<b.length;oe++)ne=b[oe],ce=ee+Ce(ne,oe),Ae+=$(ne,j,k,ce,Ee);else if(oe=V(b),typeof oe=="function")for(b=oe.call(b),oe=0;!(ne=b.next()).done;)ne=ne.value,ce=ee+Ce(ne,oe++),Ae+=$(ne,j,k,ce,Ee);else if(ce==="object"){if(typeof b.then=="function")return $(q(b),j,k,ne,Ee);throw j=String(b),Error("Objects are not valid as a React child (found: "+(j==="[object Object]"?"object with keys {"+Object.keys(b).join(", ")+"}":j)+"). If you meant to render a collection of children, use an array instead.")}return Ae}function le(b,j,k){if(b==null)return b;var ne=[],Ee=0;return $(b,ne,"","",function(ce){return j.call(k,ce,Ee++)}),ne}function he(b){if(b._status===-1){var j=b._result,k=j();k.then(function(ne){(b._status===0||b._status===-1)&&(b._status=1,b._result=ne,k.status===void 0&&(k.status="fulfilled",k.value=ne))},function(ne){(b._status===0||b._status===-1)&&(b._status=2,b._result=ne,k.status===void 0&&(k.status="rejected",k.reason=ne))}),b._status===-1&&(b._status=0,b._result=k)}if(b._status===1)return b._result.default;throw b._result}var G=typeof reportError=="function"?reportError:function(b){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var j=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof b=="object"&&b!==null&&typeof b.message=="string"?String(b.message):String(b),error:b});if(!window.dispatchEvent(j))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",b);return}console.error(b)};function ze(b){var j=B.T,k={};k.types=j!==null?j.types:null,B.T=k;try{var ne=b(),Ee=B.S;Ee!==null&&Ee(k,ne),typeof ne=="object"&&ne!==null&&typeof ne.then=="function"&&ne.then(X,G)}catch(ce){G(ce)}finally{j!==null&&k.types!==null&&(j.types=k.types),B.T=j}}function We(b){var j=B.T;if(j!==null){var k=j.types;k===null?j.types=[b]:k.indexOf(b)===-1&&k.push(b)}else ze(We.bind(null,b))}var ht={map:le,forEach:function(b,j,k){le(b,function(){j.apply(this,arguments)},k)},count:function(b){var j=0;return le(b,function(){j++}),j},toArray:function(b){return le(b,function(j){return j})||[]Line truncated
|
||||
* @license React
|
||||
* scheduler.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var Ah;function sp(){return Ah||(Ah=1,(function(i){function r(q,$){var le=q.length;q.push($);e:for(;0<le;){var he=le-1>>>1,G=q[he];if(0<d(G,$))q[he]=$,q[le]=G,le=he;else break e}}function s(q){return q.length===0?null:q[0]}function o(q){if(q.length===0)return null;var $=q[0],le=q.pop();if(le!==$){q[0]=le;e:for(var he=0,G=q.length,ze=G>>>1;he<ze;){var We=2*(he+1)-1,ht=q[We],b=We+1,j=q[b];if(0>d(ht,le))b<G&&0>d(j,ht)?(q[he]=j,q[b]=le,he=b):(q[he]=ht,q[We]=le,he=We);else if(b<G&&0>d(j,le))q[he]=j,q[b]=le,he=b;else break e}}return $}function d(q,$){var le=q.sortIndex-$.sortIndex;return le!==0?le:q.id-$.id}if(i.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var m=performance;i.unstable_now=function(){return m.now()}}else{var g=Date,S=g.now();i.unstable_now=function(){return g.now()-S}}var O=[],x=[],z=1,h=null,R=3,D=!1,V=!1,Z=!1,Y=!1,K=typeof setTimeout=="function"?setTimeout:null,H=typeof clearTimeout=="function"?clearTimeout:null,F=typeof setImmediate<"u"?setImmediate:null;function ue(q){for(var $=s(x);$!==null;){if($.callback===null)o(x);else if($.startTime<=q)o(x),$.sortIndex=$.expirationTime,r(O,$);else break;$=s(x)}}function fe(q){if(Z=!1,ue(q),!V)if(s(O)!==null)V=!0,ae||(ae=!0,Re());else{var $=s(x);$!==null&&Ce(fe,$.startTime-q)}}var ae=!1,X=-1,B=5,L=-1;function J(){return Y?!0:!(i.unstable_now()-L<B)}function de(){if(Y=!1,ae){var q=i.unstable_now();L=q;var $=!0;try{e:{V=!1,Z&&(Z=!1,H(X),X=-1),D=!0;var le=R;try{t:{for(ue(q),h=s(O);h!==null&&!(h.expirationTime>q&&J());){var he=h.callback;if(typeof he=="function"){h.callback=null,R=h.priorityLevel;var G=he(h.expirationTime<=q);if(q=i.unstable_now(),typeof G=="function"){h.callback=G,ue(q),$=!0;break t}h===s(O)&&o(O),ue(q)}else o(O);h=s(O)}if(h!==null)$=!0;else{var ze=s(x);ze!==null&&Ce(fe,ze.startTime-q),$=!1}}break e}finally{h=null,R=le,D=!1}$=void 0}}finally{$?Re():ae=!1}}}var Re;if(typeof F=="function")Re=function(){F(de)};else if(typeof MessageChannel<"u"){var He=new MessageChannel,Xe=He.port2;He.port1.onmessage=de,Re=function(){Xe.postMessage(null)}}else Re=function(){K(de,0)};function Ce(q,$){X=K(function(){q(i.unstable_now())},$)}i.unstable_IdlePriority=5,i.unstable_ImmediatePriority=1,i.unstable_LowPriority=4,i.unstable_NormalPriority=3,i.unstable_Profiling=null,i.unstable_UserBlockingPriority=2,i.unstable_cancelCallback=function(q){q.callback=null},i.unstable_forceFrameRate=function(q){0>q||125<q?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):B=0<q?Math.floor(1e3/q):5},i.unstable_getCurrentPriorityLevel=function(){return R},i.unstable_next=function(q){switch(R){case 1:case 2:case 3:var $=3;break;default:$=R}var le=R;R=$;try{return q()}finally{R=le}},i.unstable_requestPaint=function(){Y=!0},i.unstable_runWithPriority=function(q,$){switch(q){case 1:case 2:case 3:case 4:case 5:break;default:q=3}var le=R;R=q;try{return $()}finally{R=le}},i.unstable_scheduleCallback=function(q,$,le){var he=i.unstable_now();switch(typeof le=="object"&&le!==null?(le=le.delay,le=typeof le=="number"&&0<le?he+le:he):le=he,q){case 1:var G=-1;break;case 2:G=250;break;case 5:G=1073741823;break;case 4:G=1e4;break;default:G=5e3}return G=le+G,q={id:z++,callback:$,priorityLevel:q,startTime:le,expirationTime:G,sortIndex:-1},le>he?(q.sortIndex=le,r(x,q),s(O)===null&&q===s(x)&&(Z?(H(X),X=-1):Z=!0,Ce(fe,le-he))):(q.sortIndex=G,r(O,q),V||D||(V=!0,ae||(ae=!0,Re()))),q},i.unstable_shouldYield=J,i.unstable_wrapCallback=function(q){var $=R;return function(){var le=R;R=$;try{return q.apply(this,arguments)}finally{R=le}}}})(ps)),ps}var Nh;function fp(){return Nh||(Nh=1,bs.exports=sp()),bs.exports}var Ss={exports:{}},vt={};/**
|
||||
* @license React
|
||||
* react-dom.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var Oh;function dp(){if(Oh)return vt;Oh=1;var i=ws();function r(z){var h="https://react.dev/errors/"+z;if(1<arguments.length){h+="?args[]="+encodeURIComponent(arguments[1]);for(var R=2;R<arguments.length;R++)h+="&args[]="+encodeURIComponent(arguments[R])}return"Minified React error #"+z+"; visit "+h+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function s(){}var o={d:{f:s,r:function(){throw Error(r(522))},D:s,C:s,L:s,m:s,X:s,S:s,M:s},p:0,findDOMNode:null},d=Symbol.for("react.portal"),m=Symbol.for("react.recoverable"),g=Symbol.for("react.optimistic_key");function S(z,h,R){var D=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:d,key:D==null?null:D===g?g:""+D,children:z,containerInfo:h,implementation:R}}var O=i.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function x(z,h){if(z==="font")return"";if(typeof h=="string")return h==="use-credentials"?h:""}return vt.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=o,vt.browser=function(z){return{$$typeof:m,_reason:z}},vt.createPortal=function(z,h){var R=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!h||h.nodeType!==1&&h.nodeType!==9&&h.nodeType!==11)throw Error(r(299));return S(z,h,null,R)},vt.flushSync=function(z){var h=O.T,R=o.p;try{if(O.T=null,o.p=2,z)return z()}finally{O.T=h,o.p=R,o.d.f()}},vt.preconnect=function(z,h){typeof z=="string"&&(h?(h=h.crossOrigin,h=typeof h=="string"?h==="use-credentials"?h:"":void 0):h=null,o.d.C(z,h))},vt.prefetchDNS=function(z){typeof z=="string"&&o.d.D(z)},vt.preinit=function(z,h){if(typeof z=="string"&&h&&typeof h.as=="string"){var R=h.as,D=x(R,h.crossOrigin),V=typeof h.integrity=="string"?h.integrity:void 0,Z=typeof h.fetchPriority=="string"?h.fetchPriority:void 0;R==="style"?o.d.S(z,typeof h.precedence=="string"?h.precedence:void 0,{crossOrigin:D,integrity:V,fetchPriority:Z}):R==="script"&&o.d.X(z,{crossOrigin:D,integrity:V,fetchPriority:Z,nonce:typeof h.nonce=="string"?h.nonce:void 0})}},vt.preinitModule=function(z,h){if(typeof z=="string")if(typeof h=="object"&&h!==null){if(h.as==null||h.as==="script"){var R=x(h.as,h.crossOrigin);o.d.M(z,{crossOrigin:R,integrity:typeof h.integrity=="string"?h.integrity:void 0,nonce:typeof h.nonce=="string"?h.nonce:void 0,fetchPriority:typeof h.fetchPriority=="string"?h.fetchPriority:void 0})}}else h==null&&o.d.M(z)},vt.preload=function(z,h){if(typeof z=="string"&&typeof h=="object"&&h!==null&&typeof h.as=="string"){var R=h.as,D=x(R,h.crossOrigin);o.d.L(z,R,{crossOrigin:D,integrity:typeof h.integrity=="string"?h.integrity:void 0,nonce:typeof h.nonce=="string"?h.nonce:void 0,type:typeof h.type=="string"?h.type:void 0,fetchPriority:typeof h.fetchPriority=="string"?h.fetchPriority:void 0,referrerPolicy:typeof h.referrerPolicy=="string"?h.referrerPolicy:void 0,imageSrcSet:typeof h.imageSrcSet=="string"?h.imageSrcSet:void 0,imageSizes:typeof h.imageSizes=="string"?h.imageSizes:void 0,media:typeof h.media=="string"?h.media:void 0})}},vt.preloadModule=function(z,h){if(typeof z=="string")if(h){var R=x(h.as,h.crossOrigin);o.d.m(z,{as:typeof h.as=="string"&&h.as!=="script"?h.as:void 0,crossOrigin:R,integrity:typeof h.integrity=="string"?h.integrity:void 0,nonce:typeof h.nonce=="string"?h.nonce:void 0,fetchPriority:typeof h.fetchPriority=="string"?h.fetchPriority:void 0})}else o.d.m(z)},vt.requestFormReset=function(z){o.d.r(z)},vt.unstable_batchedUpdates=function(z,h){return z(h)},vt.useFormState=function(z,h,R){return O.H.useFormState(z,h,R)},vt.useFormStatus=function(){return O.H.useHostTransitionStatus()},vt.version="19.3.0",vt}var xh;function Ph(){if(xh)return Ss.exports;xh=1;function i(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(i)}catch(r){console.error(r)}}return i(),Ss.exports=dp(),Ss.exports}/**
|
||||
* @license React
|
||||
* react-dom-client.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var Rh;function mp(){if(Rh)return qu;Rh=1;var i=fp(),r=ws(),s=Ph();function o(e){var t="https://react.dev/errors/"+e;if(1<arguments.length){t+="?args[]="+encodeURIComponent(arguments[1]);for(var l=2;l<arguments.length;l++)t+="&args[]="+encodeURIComponent(arguments[l])}return"Minified React error #"+e+"; visit "+t+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function d(e){return!(!e||e.nodeType!==1&&e.nodeType!==9&&e.nodeType!==11)}function m(e){for(var t=e,l=t;l&&!l.alternate;)t=l,(t.flags&4098)!==0&&(e=t.return),l=t.return;for(;t.return;)t=t.return;return t.tag===3?e:null}function g(e){if(e.tag===13){var t=e.memoizedState;if(t===null&&(e=e.alternate,e!==null&&(t=e.memoizedState)),t!==null)return t.dehydrated}return null}function S(e){if(e.tag===31){var t=e.memoizedState;if(t===null&&(e=e.alternate,e!==null&&(t=e.memoizedState)),t!==null)return t.dehydrated}return null}function O(e){if(m(e)!==e)throw Error(o(188))}function x(e){var t=e.alternate;if(!t){if(t=m(e),t===null)throw Error(o(188));return t!==e?null:e}for(var l=e,n=t;;){var a=l.return;if(a===null)break;var u=a.alternate;if(u===null){if(n=a.return,n!==null){l=n;continue}break}if(a.child===u.child){for(u=a.child;u;){if(u===l)return O(a),e;if(u===n)return O(a),t;u=u.sibling}throw Error(o(188))}if(l.return!==n.return)l=a,n=u;else{for(var c=!1,f=a.child;f;){if(f===l){c=!0,l=a,n=u;break}if(f===n){c=!0,n=a,l=u;break}f=f.sibling}if(!c){for(f=u.child;f;){if(f===l){c=!0,l=u,n=a;break}if(f===n){c=!0,n=u,l=a;break}f=f.sibling}if(!c)throw Error(o(189))}}if(l.alternate!==n)throw Error(o(190))}if(l.tag!==3)throw Error(o(188));return l.stateNode.current===l?e:t}function z(e){var t=e.tag;if(t===5||t===26||t===27||t===6)return e;for(e=e.child;e!==null;){if(t=z(e),t!==null)return t;e=e.sibling}return null}function h(e,t,l,n,a,u){for(;e!==null;){if((e.tag===5||e.tag===27||e.tag===6)&&l(e,n,a,u)||(e.tag!==22||e.memoizedState===null)&&(t||e.tag!==5&&e.tag!==27)&&h(e.child,t,l,n,a,u))return!0;e=e.sibling}return!1}function R(e){for(e=e.return;e!==null;){if(e.tag===3||e.tag===5||e.tag===27)return e;e=e.return}return null}function D(e){var t=!1;for(e=e.return;e!==null&&(e.tag===4&&(t=!0),!(e.tag===3||e.tag===5||e.tag===27));)e=e.return;return t}function V(e){var t=[null,null],l=R(e);return l===null||Z(t,e,l.child,{foundSelf:!1}),t}function Z(e,t,l,n){for(;l!==null;){if(l===t)n.foundSelf=!0;else if(l.tag===5||l.tag===27||l.tag===6){if(n.foundSelf)return e[1]=l,!0;e[0]=l}else if((l.tag!==22||l.memoizedState===null)&&Z(e,t,l.child,n))return!0;l=l.sibling}return!1}function Y(e){switch(e.tag){case 5:case 27:case 6:return e.stateNode;case 3:return e.stateNode.containerInfo;default:throw Error(o(559))}}var K=null,H=null;function F(e,t,l){return e===l?!0:e===t?(K=e,!0):!1}function ue(e,t,l){return e===l?(H=e,!1):e===t?(H!==null&&(K=e),!0):!1}function fe(e){if(e===null)return null;do e=e===null?null:e.return;while(e&&e.tag!==5&&e.tag!==27&&e.tag!==3);return e||null}function ae(e,t,l){for(var n=0,a=e;a;a=l(a))n++;a=0;for(var u=t;u;u=l(u))a++;for(;0<n-a;)e=l(e),n--;for(;0<a-n;)t=l(t),a--;for(;n--;){if(e===t||t!==null&&e===t.alternate)return e;e=l(e),t=l(t)}return null}var X=Object.assign,B=Symbol.for("react.element"),L=Symbol.for("react.transitional.element"),J=Symbol.for("react.portal"),de=Symbol.for("react.fragment"),Re=Symbol.for("react.strict_mode"),He=Symbol.for("react.profiler"),Xe=Symbol.for("react.consumer"),Ce=Symbol.for("react.context"),q=Symbol.for("react.forward_ref"),$=Symbol.for("react.suspense"),le=Symbol.for("react.suspense_list"),he=Symbol.for("react.memo"),G=Symbol.for("react.lazy"),ze=Symbol.for("react.activity"),We=Symbol.for("react.legacy_hidden"),ht=Symbol.for("react.memo_cache_sentinel"),b=Symbol.for("react.view_transition"),j=Symbol.for("react.recoverable"),k=Symbol.iterator;function ne(e){return e===null||typeof e!="object"?null:(e=k&&e[k]||e["@@iterator"],typeof e=="function"?e:null)}var Ee=Symbol.for("react.client.reference");function ce(e){if(e==null)return null;if(typeof e=="function")return e.$$typeof===Ee?null:e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case de:return"Fragment";case He:return"Profiler";case Re:return"StrictMode";case $:return"Suspense";case le:return"SuspenseList";case ze:return"Activity";case b:return"ViewTransition"}if(typeof e=="object")switch(e.$$typeof){case J:return"Portal";case Ce:return e.displayName||"Context";case Xe:return(e._context.displayName||"Context")+".Consumer";case q:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case he:return t=e.displayName||null,t!==null?t:ce(e.type)||"Memo";case G:t=e._payload,e=e._init;try{return ce(e(t))}catch{}}return null}var Ae=Array.isArray,ee=r.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,oe=s.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,Nt={pending:!1,data:null,method:null,action:null},Ll=[],Line truncated
|
||||
at`)?" (<anonymous>)":-1<l.stack.indexOf("@")?"@unknown:0:0":""}return`
|
||||
`+Hc+e+Is}var jc=!1;function Gc(e,t){if(!e||jc)return"";jc=!0;var l=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var n={DetermineComponentFrameRoot:function(){try{if(t){var U=function(){throw Error()};if(Object.defineProperty(U.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(U,[])}catch(Q){var p=Q}Reflect.construct(e,[],U)}else{try{U.call()}catch(Q){p=Q}U=!1;try{var C=Object.getOwnPropertyDescriptor(e.prototype,"props");Object.defineProperty(e.prototype,"props",{configurable:!0,set:function(){throw Error()}}),U=!0,new e}finally{U&&(C!==void 0?Object.defineProperty(e.prototype,"props",C):delete e.prototype.props)}}}else{try{throw Error()}catch(Q){p=Q}(U=e())&&typeof U.catch=="function"&&U.catch(function(){})}}catch(Q){if(Q&&p&&typeof Q.stack=="string")return[Q.stack,p.stack]}return[null,null]}};n.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var a=Object.getOwnPropertyDescriptor(n.DetermineComponentFrameRoot,"name");a&&a.configurable&&Object.defineProperty(n.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var u=n.DetermineComponentFrameRoot(),c=u[0],f=u[1];if(c&&f){var v=c.split(`
|
||||
`),T=f.split(`
|
||||
`);for(a=n=0;n<v.length&&!v[n].includes("DetermineComponentFrameRoot");)n++;for(;a<T.length&&!T[a].includes("DetermineComponentFrameRoot");)a++;if(n===v.length||a===T.length)for(n=v.length-1,a=T.length-1;1<=n&&0<=a&&v[n]!==T[a];)a--;for(;1<=n&&0<=a;n--,a--)if(v[n]!==T[a]){if(n!==1||a!==1)do if(n--,a--,0>a||v[n]!==T[a]){var A=`
|
||||
`+v[n].replace(" at new "," at ");return e.displayName&&A.includes("<anonymous>")&&(A=A.replace("<anonymous>",e.displayName)),A}while(1<=n&&0<=a);break}}}finally{jc=!1,Error.prepareStackTrace=l}return(l=e?e.displayName||e.name:"")?Xl(l):""}function fg(e,t){switch(e.tag){case 26:case 27:case 5:return Xl(e.type);case 16:return Xl("Lazy");case 13:return e.child!==t&&t!==null?Xl("Suspense Fallback"):Xl("Suspense");case 19:return Xl("SuspenseList");case 0:case 15:return Gc(e.type,!1);case 11:return Gc(e.type.render,!1);case 1:return Gc(e.type,!0);case 31:return Xl("Activity");case 30:return Xl("ViewTransition");default:return""}}function Fs(e){try{var t="",l=null;do t+=fg(e,l),l=e,e=e.return;while(e);return t}catch(n){return`
|
||||
Error generating stack: `+n.message+`
|
||||
`+n.stack}}var Bc=Object.prototype.hasOwnProperty,qc=i.unstable_scheduleCallback,Yc=i.unstable_cancelCallback,dg=i.unstable_shouldYield,mg=i.unstable_requestPaint,Ot=i.unstable_now,vg=i.unstable_getCurrentPriorityLevel,Ws=i.unstable_ImmediatePriority,Ps=i.unstable_UserBlockingPriority,Ku=i.unstable_NormalPriority,hg=i.unstable_LowPriority,ef=i.unstable_IdlePriority,gg=i.log,yg=i.unstable_setDisableYieldValue,ka=null,xt=null;function Ql(e){if(typeof gg=="function"&&yg(e),xt&&typeof xt.setStrictMode=="function")try{xt.setStrictMode(ka,e)}catch{}}var Rt=Math.clz32?Math.clz32:Sg,bg=Math.log,pg=Math.LN2;function Sg(e){return e>>>=0,e===0?32:31-(bg(e)/pg|0)|0}var Ju=256,$u=262144,Iu=4194304;function En(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&-e;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function Fu(e,t,l){var n=e.pendingLanes;if(n===0)return 0;var a=0,u=e.suspendedLanes,c=e.pingedLanes;e=e.warmLanes;var f=n&134217727;return f!==0?(n=f&~u,n!==0?a=En(n):(c&=f,c!==0?a=En(c):l||(l=f&~e,l!==0&&(a=En(l))))):(f=n&~u,f!==0?a=En(f):c!==0?a=En(c):l||(l=n&~e,l!==0&&(a=En(l)))),a===0?0:t!==0&&t!==a&&(t&u)===0&&(u=a&-a,l=t&-t,u>=l||u===32&&(l&4194048)!==0)?t:a}function Ka(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function tf(e,t){(t&8)!==0&&(t|=t&32);var l=e.entangledLanes;if(l!==0)for(e=e.entanglements,l&=t;0<l;){var n=31-Rt(l),a=1<<n;t|=e[n],l&=~a}return t}function Eg(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function lf(){var e=Iu;return Iu<<=1,(Iu&62914560)===0&&(Iu=4194304),e}function Lc(e){for(var t=[],l=0;31>l;l++)t.push(e);return t}function Ja(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function Tg(e,t,l,n,a,u){var c=e.pendingLanes;e.pendingLanes=l,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=l,e.entangledLanes&=l,e.errorRecoveryDisabledLanes&=l,e.shellSuspendCounter=0;var f=e.entanglements,v=e.expirationTimes,T=e.hiddenUpdates;for(l=c&~l;0<l;){var A=31-Rt(l),U=1<<A;f[A]=0,v[A]=-1;var p=T[A];if(p!==null)for(T[A]=null,A=0;A<p.length;A++){var C=p[A];C!==null&&(C.lane&=-536870913)}l&=~U}n!==0&&nf(e,n,0),u!==0&&a===0&&e.tag!==0&&(e.suspendedLanes|=u&~(c&~t))}function nf(e,t,l){e.pendingLanes|=t,e.suspendedLanes&=~t;var n=31-Rt(t);e.entangledLanes|=t,e.entanglements[n]=e.entanglements[n]|1073741824|l&261930}function af(e,t){var l=e.entangledLanes|=t;for(e=e.entanglements;l;){var n=31-Rt(l),a=1<<n;a&t|e[n]&t&&(e[n]|=t),l&=~a}}function uf(e,t){var l=t&-t;return l=(l&42)!==0?1:Vc(l),(l&(e.suspendedLanes|t))!==0?0:l}function Vc(e){switch(e){case 2:e=1;break;case 8:e=4;break;case 32:e=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:e=128;break;case 268435456:e=134217728;break;default:e=0}return e}function Xc(e){return e&=-e,2<e?8<e?(e&134217727)!==0?32:268435456:8:2}function cf(){var e=oe.p;return e!==0?e:(e=window.event,e===void 0?32:vh(e.type))}function of(e,t){var l=oe.p;try{return oe.p=e,t()}finally{oe.p=l}}var Sl=Math.random().toString(36).slice(2),ct="__reactFiber$"+Sl,Et="__reactProps$"+Sl,Wn="__reactContainer$"+Sl,rf="__reactEvents$"+Sl,_g="__reactListeners$"+Sl,Cg="__reactHandles$"+Sl,sf="__reactResources$"+Sl,$a="__reactMarker$"+Sl,Wu="__reactLoad$"+Sl;function Pu(e){delete e[ct],delete e[Et],delete e[_g],delete e[Cg]}function Tn(e){var t;if(t=e[ct])return t;for(var l=e.parentNode;l;){if(t=l[Wn]||l[ct]){if(l=t.alternate,t.child!==null||l!==null&&l.child!==null)for(e=Jv(e);e!==null;){if(l=e[ct])return l;e=Jv(e)}return t}e=l,l=e.parentNode}return null}function Pn(e){if(e=e[ct]||e[Wn]){var t=e.tag;if(t===5||t===6||t===13||t===31||t===26||t===27||t===3)return e}return null}function Ia(e){var t=e.tag;if(t===5||t===26||t===27||t===6)return e.stateNode;throw Error(o(33))}function ea(e){var t=e[sf];return t||(t=e[sf]={hoistableStyles:new Map,hoistableScripts:new Map}),t}function lt(e){e[$a]=!0}function ff(e){e[Wu]=void 0}var df=new Set,mf={};function _n(e,t){ta(e,t),ta(e+"Capture",t)}funct
Line truncated
|
||||
`).replace(fb,"")}function Ov(e,t){return t=Nv(t),Nv(e)===t}function Ue(e,t,l,n,a,u){switch(l){case"children":if(typeof n=="string")t==="body"||t==="textarea"&&n===""||na(e,n);else if(typeof n=="number"||typeof n=="bigint")t!=="body"&&na(e,""+n);else return;break;case"className":ti(e,"class",n);break;case"tabIndex":ti(e,"tabindex",n);break;case"dir":case"role":case"viewBox":case"width":case"height":ti(e,l,n);break;case"style":_f(e,n,u);return;case"data":if(t!=="object"){ti(e,"data",n);break}case"src":case"href":if(n===""&&(t!=="a"||l!=="href")){e.removeAttribute(l);break}if(n==null||typeof n=="function"||typeof n=="symbol"||typeof n=="boolean"){e.removeAttribute(l);break}n=li(n),e.setAttribute(l,n);break;case"action":case"formAction":if(typeof n=="function"){e.setAttribute(l,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof u=="function"&&(l==="formAction"?(t!=="input"&&Ue(e,t,"name",a.name,a,null),Ue(e,t,"formEncType",a.formEncType,a,null),Ue(e,t,"formMethod",a.formMethod,a,null),Ue(e,t,"formTarget",a.formTarget,a,null)):(Ue(e,t,"encType",a.encType,a,null),Ue(e,t,"method",a.method,a,null),Ue(e,t,"target",a.target,a,null)));if(n==null||typeof n=="symbol"||typeof n=="boolean"){e.removeAttribute(l);break}n=li(n),e.setAttribute(l,n);break;case"onClick":n!=null&&(e.onclick=cl);return;case"onScroll":n!=null&&Se("scroll",e);return;case"onScrollEnd":n!=null&&Se("scrollend",e);return;case"dangerouslySetInnerHTML":if(n!=null){if(typeof n!="object"||!("__html"in n))throw Error(o(61));if(l=n.__html,l!=null){if(a.children!=null)throw Error(o(60));(u!=null?u.__html:void 0)!==l&&(e.innerHTML=l)}}break;case"multiple":e.multiple=n&&typeof n!="function"&&typeof n!="symbol";break;case"muted":e.muted=n&&typeof n!="function"&&typeof n!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(n==null||typeof n=="function"||typeof n=="boolean"||typeof n=="symbol"){e.removeAttribute("xlink:href");break}l=li(n),e.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",l);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":n!=null&&typeof n!="function"&&typeof n!="symbol"?e.setAttribute(l,n):e.removeAttribute(l);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"credentialless":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":n&&typeof n!="function"&&typeof n!="symbol"?e.setAttribute(l,""):e.removeAttribute(l);break;case"capture":case"download":n===!0?e.setAttribute(l,""):n!==!1&&n!=null&&typeof n!="function"&&typeof n!="symbol"?e.setAttribute(l,n):e.removeAttribute(l);break;case"cols":case"rows":case"size":case"span":n!=null&&typeof n!="function"&&typeof n!="symbol"&&!isNaN(n)&&1<=n?e.setAttribute(l,n):e.removeAttribute(l);break;case"rowSpan":case"start":n==null||typeof n=="function"||typeof n=="symbol"||isNaN(n)?e.removeAttribute(l):e.setAttribute(l,n);break;case"popover":Se("beforetoggle",e),Se("toggle",e),ei(e,"popover",n);break;case"xlinkActuate":El(e,"http://www.w3.org/1999/xlink","xlink:actuate",n);break;case"xlinkArcrole":El(e,"http://www.w3.org/1999/xlink","xlink:arcrole",n);break;case"xlinkRole":El(e,"http://www.w3.org/1999/xlink","xlink:role",n);break;case"xlinkShow":El(e,"http://www.w3.org/1999/xlink","xlink:show",n);break;case"xlinkTitle":El(e,"http://www.w3.org/1999/xlink","xlink:title",n);break;case"xlinkType":El(e,"http://www.w3.org/1999/xlink","xlink:type",n);break;case"xmlBase":El(e,"http://www.w3.org/XML/1998/namespace","xml:base",n);break;case"xmlLang":El(e,"http://www.w3.org/XML/1998/namespace","xml:lang",n);break;case"xmlSpace":El(e,"http://www.w3.org/XML/1998/namespace","xml:space",n);break;case"is":ei(e,"is",n);break;case"innerText":case"textContent":return;default:if(!(2<l.length)||l[0]!=="o"&&l[0]!=="O"||l[1]!=="n"&&l[1]!=="N")l=Rg.get(l)||l,ei(e,l,n);else return}Oe=!0}function kr(e,t,l,n,a,u){switch(l){case"style":_f(e,n,u);return;case"dangerouslySetInnerHTML":if(n!=null){if(typeof n!="object"||!("__html"in n))throw Error(o(61));if(l=n.__html,l!=null){if(a.children!=null)throw Error(o(60));(u!=null?u.__html:void 0)!==l&&(e.innerHTML=l)}}break;case"children":if(typeof n=="string")na(e,n);else if(typeof n=="number"||typeof n=="bigint")na(e,""+n);else return;break;case"onScroll":n!=null&&Se("scroll",e);return;case"onScrollEnd":n!=null&&Se("scro
Line truncated
|
||||
* @license React
|
||||
* react-jsx-runtime.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var Yh;function y1(){if(Yh)return Vu;Yh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.fragment");function s(o,d,m){var g=null;if(m!==void 0&&(g=""+m),d.key!==void 0&&(g=""+d.key),"key"in d){m={};for(var S in d)S!=="key"&&(m[S]=d[S])}else m=d;return d=m.ref,{$$typeof:i,type:o,key:g,ref:d!==void 0?d:null,props:m}}return Vu.Fragment=r,Vu.jsx=s,Vu.jsxs=s,Vu}var Lh;function b1(){return Lh||(Lh=1,Ts.exports=y1()),Ts.exports}var be=b1(),p1=Object.defineProperty,Ft=(i,r)=>p1(i,"name",{value:r,configurable:!0});function S1(i,r){const s=M.createContext(r);s.displayName=i+"Context";const o=Ft(m=>{const{children:g,...S}=m,O=M.useMemo(()=>S,Object.values(S));return be.jsx(s.Provider,{value:O,children:g})},"Provider");o.displayName=i+"Provider";function d(m,g={}){const{optional:S=!1}=g,O=M.useContext(s);if(O)return O;if(r!==void 0)return r;if(!S)throw new Error(`\`${m}\` must be used within \`${i}\``)}return Ft(d,"useContext"),[o,d]}Ft(S1,"createContext");function Va(i,r=[]){let s=[];function o(m,g){const S=M.createContext(g);S.displayName=m+"Context";const O=s.length;s=[...s,g];const x=Ft(h=>{var K;const{scope:R,children:D,...V}=h,Z=((K=R==null?void 0:R[i])==null?void 0:K[O])||S,Y=M.useMemo(()=>V,Object.values(V));return be.jsx(Z.Provider,{value:Y,children:D})},"Provider");x.displayName=m+"Provider";function z(h,R,D={}){var K;const{optional:V=!1}=D,Z=((K=R==null?void 0:R[i])==null?void 0:K[O])||S,Y=M.useContext(Z);if(Y)return Y;if(g!==void 0)return g;if(!V)throw new Error(`\`${h}\` must be used within \`${m}\``)}return Ft(z,"useContext"),[x,z]}Ft(o,"createContext");const d=Ft(()=>{const m=s.map(g=>M.createContext(g));return Ft(function(S){const O=(S==null?void 0:S[i])||m;return M.useMemo(()=>({[`__scope${i}`]:{...S,[i]:O}}),[S,O])},"useScope")},"createScope");return d.scopeName=i,[o,z0(d,...r)]}Ft(Va,"createContextScope");function z0(...i){const r=i[0];if(i.length===1)return r;const s=Ft(()=>{const o=i.map(d=>({useScope:d(),scopeName:d.scopeName}));return Ft(function(m){const g=o.reduce((S,{useScope:O,scopeName:x})=>{const h=O(m)[`__scope${x}`];return{...S,...h}},{});return M.useMemo(()=>({[`__scope${r.scopeName}`]:g}),[g])},"useComposedScopes")},"createScope");return s.scopeName=r.scopeName,s}Ft(z0,"composeContextScopes");var E1=Object.defineProperty,Xa=(i,r)=>E1(i,"name",{value:r,configurable:!0}),A0=!!(typeof window<"u"&&window.document&&window.document.createElement);function Yt(i,r,{checkForDefaultPrevented:s=!0}={}){return Xa(function(d){if(i==null||i(d),s===!1||!d||!d.defaultPrevented)return r==null?void 0:r(d)},"handleEvent")}Xa(Yt,"composeEventHandlers");function T1(i){var r;if(!A0)throw new Error("Cannot access window outside of the DOM");return((r=i==null?void 0:i.ownerDocument)==null?void 0:r.defaultView)??window}Xa(T1,"getOwnerWindow");function xs(i){if(!A0)throw new Error("Cannot access document outside of the DOM");return(i==null?void 0:i.ownerDocument)??document}Xa(xs,"getOwnerDocument");function N0(i,r=!1){const{activeElement:s}=xs(i);if(!(s!=null&&s.nodeName))return null;if(O0(s)&&s.contentDocument)return N0(s.contentDocument.body,r);if(r){const o=s.getAttribute("aria-activedescendant");if(o){const d=xs(s).getElementById(o);if(d)return d}}return s}Xa(N0,"getActiveElement");function O0(i){return i.tagName==="IFRAME"}Xa(O0,"isFrame");var Gl=globalThis!=null&&globalThis.document?M.useLayoutEffect:()=>{},_1=Object.defineProperty,C1=(i,r)=>_1(i,"name",{value:r,configurable:!0}),Vh=kn[" useEffectEvent ".trim().toString()],Xh=kn[" useInsertionEffect ".trim().toString()];function x0(i){if(typeof Vh=="function")return Vh(i);const r=M.useRef(()=>{throw new Error("Cannot call an event handler while rendering.")});return typeof Xh=="function"?Xh(()=>{r.current=i}):Gl(()=>{r.current=i}),M.useMemo(()=>((...s)=>{var o;return(o=r.current)==null?void 0:o.call(r,...s)}),[])}C1(x0,"useEffectEvent");var z1=Object.defineProperty,Xu=(i,r)=>z1(i,"name",{value:r,configurable:!0}),A1=kn[" useInsertionEffect ".trim().toString()]||Gl;function Dc({prop:i,defaultProp:r,onChange:s=Xu(()=>{},"onChange"),caller:o}){const[d,m,g]=R0({defaultProp:r,onChange:s}),S=i!==void 0,O=S?i:d,x=M.useCallback(z=>{var h;if(S){const R=M0(z)?z(i):z;R!==i&&((h=g.current)==null||h.call(g,R))}else m(z)},[S,i,m,g]);return[O,x]}Xu(Dc,"useControllableState");function R0({defaultProp:i,onChange:r}){const[s,o]=M.useState(i),d=M.useRef(s),m=M.useRef(r);return A1(()=>{m.current=r},[r]),M.useEffect(()=>{var g;d.current!==s&&((g=m.current)==null||g.call(m,s),d.current=s)},[s,d]),[s,o,m]}Xu(R0,"useUncontrolledState");function M0(i){return typeof i=="function"}Xu(M0,"isFunction");var Qh=Symbol("RADIX:SYNC_STATE");function N1(i,r,s,o){const{prop:d,defaultProp:m,onChange:g,caller:S}=r,O=d!==void 0,x=x0(g),z=[{...s,state:m}];o&&z.push(o);const[h,R]=M.useReducer((Y,K)=>{if(K.type===Qh)return{...Y,state:K.state};const H=i(Y,K);return O&&!Object.is(H.state,Y.state)&&x(H.state),H},...z),D=h.state,V=M.useRef(D);M.useEffect(()=>{V.current!==D&&(V.Line truncated
|
||||
* @license lucide-react v0.468.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const Z1=i=>i.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),L0=(...i)=>i.filter((r,s,o)=>!!r&&r.trim()!==""&&o.indexOf(r)===s).join(" ").trim();/**
|
||||
* @license lucide-react v0.468.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/var k1={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};/**
|
||||
* @license lucide-react v0.468.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const K1=M.forwardRef(({color:i="currentColor",size:r=24,strokeWidth:s=2,absoluteStrokeWidth:o,className:d="",children:m,iconNode:g,...S},O)=>M.createElement("svg",{ref:O,...k1,width:r,height:r,stroke:i,strokeWidth:o?Number(s)*24/Number(r):s,className:L0("lucide",d),...S},[...g.map(([x,z])=>M.createElement(x,z)),...Array.isArray(m)?m:[m]]));/**
|
||||
* @license lucide-react v0.468.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const Jn=(i,r)=>{const s=M.forwardRef(({className:o,...d},m)=>M.createElement(K1,{ref:m,iconNode:r,className:L0(`lucide-${Z1(i)}`,o),...d}));return s.displayName=`${i}`,s};/**
|
||||
* @license lucide-react v0.468.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const Tc=Jn("ArrowUpRight",[["path",{d:"M7 7h10v10",key:"1tivn9"}],["path",{d:"M7 17 17 7",key:"1vkiza"}]]);/**
|
||||
* @license lucide-react v0.468.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const J1=Jn("BookOpen",[["path",{d:"M12 7v14",key:"1akyts"}],["path",{d:"M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z",key:"ruj8y"}]]);/**
|
||||
* @license lucide-react v0.468.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const $1=Jn("Check",[["path",{d:"M20 6 9 17l-5-5",key:"1gmf2c"}]]);/**
|
||||
* @license lucide-react v0.468.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const Zh=Jn("Download",[["path",{d:"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4",key:"ih7n3h"}],["polyline",{points:"7 10 12 15 17 10",key:"2ggqvy"}],["line",{x1:"12",x2:"12",y1:"15",y2:"3",key:"1vk2je"}]]);/**
|
||||
* @license lucide-react v0.468.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const kh=Jn("FileText",[["path",{d:"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z",key:"1rqfz7"}],["path",{d:"M14 2v4a2 2 0 0 0 2 2h4",key:"tnqrlb"}],["path",{d:"M10 9H8",key:"b1mrlr"}],["path",{d:"M16 13H8",key:"t4e002"}],["path",{d:"M16 17H8",key:"z1uh3a"}]]);/**
|
||||
* @license lucide-react v0.468.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const Kh=Jn("FolderTree",[["path",{d:"M20 10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-2.5a1 1 0 0 1-.8-.4l-.9-1.2A1 1 0 0 0 15 3h-2a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z",key:"hod4my"}],["path",{d:"M20 21a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-2.9a1 1 0 0 1-.88-.55l-.42-.85a1 1 0 0 0-.92-.6H13a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z",key:"w4yl2u"}],["path",{d:"M3 5a2 2 0 0 0 2 2h3",key:"f2jnh7"}],["path",{d:"M3 3v13a2 2 0 0 0 2 2h3",key:"k8epm1"}]]);/**
|
||||
* @license lucide-react v0.468.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const I1=Jn("GitBranch",[["line",{x1:"6",x2:"6",y1:"3",y2:"15",key:"17qcm7"}],["circle",{cx:"18",cy:"6",r:"3",key:"1h7g24"}],["circle",{cx:"6",cy:"18",r:"3",key:"fqmcym"}],["path",{d:"M18 9a9 9 0 0 1-9 9",key:"n2h4wq"}]]),Ms=M.forwardRef(({className:i,...r},s)=>M.createElement(q0,{ref:s,className:pn("grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",i),...r},M.createElement(V1,{className:pn("grid place-content-center text-current")},M.createElement($1,{className:"h-4 w-4"}))));Ms.displayName=q0.displayName;const V0=M.forwardRef(({className:i,...r},s)=>M.createElement("textarea",{className:pn("flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",i),ref:s,...r}));V0.displayName="Textarea";var F1=Object.defineProperty,tt=(i,r)=>F1(i,"name",{value:r,configurable:!0});function X0(i){const r=i+"CollectionProvider",[s,o]=Va(r),[d,m]=s(r,{collectionRef:{current:null},itemMap:new Map}),g=tt(Z=>{const{scope:Y,children:K}=Z,H=M.useRef(null),F=M.useRef(new Map).current;return be.jsx(d,{scope:Y,itemMap:F,collectionRef:H,children:K})},"CollectionProvider");g.displayName=r;const S=i+"CollectionSlot",O=Zn(S),x=M.forwardRef((Z,Y)=>{const{scope:K,children:H}=Z,F=m(S,K),ue=pl(Y,F.collectionRef);return be.jsx(O,{ref:ue,children:H})});x.displayName=S;const z=i+"CollectionItemSlot",h="data-radix-collection-item",R=Zn(z),D=M.forwardRef((Z,Y)=>{const{scope:K,children:H,...F}=Z,ue=M.useRef(null),fe=pl(Y,ue),ae=m(z,K);return M.useEffect(()=>(ae.itemMap.set(ue,{ref:ue,...F}),()=>void ae.itemMap.delete(ue))),be.jsx(R,{[h]:"",ref:fe,children:H})});D.displayName=z;function V(Z){const Y=m(i+"CollectionConsumer",Z);return M.useCallback(()=>{const H=Y.collectionRef.current;if(!H)return[];const F=Array.from(H.querySelectorAll(`[${h}]`));return Array.from(Y.itemMap.values()).sort((ae,X)=>F.indexOf(ae.ref.current)-F.indexOf(X.ref.current))},[Y.collectionRef,Y.itemMap])}return tt(V,"useCollection"),[{Provider:g,Slot:x,ItemSlot:D},V,o]}tt(X0,"createCollection");var Jh=new WeakMap,Ke,qt,_c=(qt=class extends Map{constructor(s){super(s);_h(this,Ke);hs(this,Ke,[...super.keys()]),Jh.set(this,!0)}set(s,o){return Jh.get(this)&&!this.has(s)&&mt(this,Ke).push(s),super.set(s,o),this}insert(s,o,d){const m=this.has(o),g=m?this.size:this.size+1,S=Ls(s),O=S>=0?Math.min(S,g-1):Math.max(g+S,0);if(m?mt(this,Ke).indexOf(o)===O:O===this.size)return this.set(o,d),this;const x=mt(this,Ke).filter(h=>h!==o);x.splice(O,0,o);const z=x.map(h=>[h,h===o?d:this.get(h)]);this.clear();for(const[h,R]of z)this.set(h,R);return this}with(s,o,d){const m=new qt(this);return m.insert(s,o,d),m}before(s){const o=mt(this,Ke).indexOf(s)-1;if(!(o<0))return this.entryAt(o)}setBefore(s,o,d){const m=mt(this,Ke).indexOf(s);if(m===-1)return this;const g=mt(this,Ke).indexOf(o),S=g!==-1&&g<m?m-1:m;return this.insert(S,o,d)}after(s){let o=mt(this,Ke).indexOf(s);if(o=o===-1||o===this.size-1?-1:o+1,o!==-1)return this.entryAt(o)}setAfter(s,o,d){const m=mt(this,Ke).indexOf(s);if(m===-1)return this;const g=mt(this,Ke).indexOf(o),S=g!==-1&&g<=m?m:m+1;return this.insert(S,o,d)}first(){return this.entryAt(0)}last(){return this.entryAt(-1)}clear(){return hs(this,Ke,[]),super.clear()}delete(s){const o=super.delete(s);return o&&mt(this,Ke).splice(mt(this,Ke).indexOf(s),1),o}deleteAt(s){const o=this.keyAt(s);return o!==void 0?this.delete(o):!1}at(s){const o=Ac(mt(this,Ke),s);if(o!==void 0)return this.get(o)}entryAt(s){const o=Ac(mt(this,Ke),s);if(o!==void 0)return[o,this.get(o)]}indexOf(s){return mt(this,Ke).indexOf(s)}keyAt(s){return Ac(mt(this,Ke),s)}from(s,o){const d=this.indexOf(s);if(d===-1)return;let m=d+o;return m<0&&(m=0),m>=this.size&&(m=this.size-1),this.at(m)}keyFrom(s,o){const d=this.indexOf(s);if(d===-1)return;let m=d+o;return m<0&&(m=0),m>=this.size&&(m=this.size-1),this.keyAt(m)}find(s,o){let d=0;for(const m of this){if(Reflect.apply(s,o,[m,d,this]))return m;d++}}findIndex(s,o){let d=0;for(const m of this){if(Reflect.apply(s,o,[m,d,this]))return d;d++}return-1}filter(s,o){const d=[];let m=0;for(const g of this)Reflect.apply(s,o,[g,m,this])&&d.push(g),m++;return new qt(d)}map(s,o){const d=[];let m=0;for(const g of this)d.push([g[0],Reflect.apply(s,o,[g,m,this])]),m++;return new qt(d)}reduce(...s){const[o,d]=s,m=s.length>1;if(!m&&this.size===0)throw new TypeError("Reduce of empty OrderedDict with no initial value");let g=0,S=d;for(const O of this)g===0&&!m?S=O:S=Reflect.apply(o,this,[S,O,g,this]),g++;return S}reduceRight(...s){const[o,d]=s,m=s.length>1;if(!m&&this.size===0)throw new TypeError("Reduce of empty OrderedDict with no initial value");letLine truncated
|
||||
`).map(r=>/\b(?:gh[pousr]_[A-Za-z0-9_]{15,}|github_pat_[A-Za-z0-9_]+|sk-[A-Za-z0-9_-]{12,}|AKIA[A-Z0-9]{16})|-----BEGIN .*PRIVATE KEY-----|\b(?:password|api[_-]?key|access[_-]?token|secret)\s*[:=]\s*["']?[^\s"']{8,}/i.test(r)?"[Sensitive-looking line withheld]":r).join(`
|
||||
`)}function Fh(i){return/(^|\/)SKILL\.md$/i.test(i)?"skill":DS(i)?"instruction":/(^|\/)(docs?|guides?)\/.*\.md$|(^|\/)(README|ARCHITECTURE)\.md$/i.test(i)?"doc":/(^|\/)(\.husky\/[^/]+|\.githooks\/[^/]+|\.pre-commit-config\.yaml|\.claude\/settings\.json)$/.test(i)?"hook":null}const GS=(i,r,s)=>`https://github.com/${i}/blob/${r}/${s.split("/").map(encodeURIComponent).join("/")}`;async function Cc(i){const r=await fetch("https://api.github.com/"+i,{credentials:"omit",headers:{Accept:"application/vnd.github+json"}});if(!r.ok)throw Error(r.status===403||r.status===429?"GitHub public API rate limit reached. Try later.":`GitHub ${r.status}: unavailable.`);return r.json()}async function BS(i,r){const s=MS(i);if(s.length!==1)throw Error("Choose one target project per architecture proposal.");const o=s[0];r("Pinning the public repository…");const d=await Cc("repos/"+o);if(d.private)throw Error("Only public repositories are supported.");const g=(await Cc(`repos/${o}/commits/${encodeURIComponent(d.default_branch)}`)).sha,S=await Cc(`repos/${o}/git/trees/${g}?recursive=1`),O=S.tree.filter(H=>HS(H.path)),x=[];S.truncated&&x.push("GitHub returned a truncated tree. Recommendations have incomplete structural coverage.");const z=O.filter(H=>H.type==="blob"&&Fh(H.path)).map(H=>({...H,kind:Fh(H.path),url:GS(o,g,H.path)})),h={instruction:0,skill:1,doc:2,hook:3};z.sort((H,F)=>h[H.kind]-h[F.kind]||H.path.split("/").length-F.path.split("/").length||H.path.localeCompare(F.path));const R=[];let D=0;const V={instruction:12,skill:6,doc:6,hook:4},Z={instruction:0,skill:0,doc:0,hook:0};for(const H of z){if(Z[H.kind]>=V[H.kind]||H.size>65536||D+H.size>524288){H.omitted=!0;continue}Z[H.kind]++,r("Reading "+H.path+"…");try{const F=await Cc(`repos/${o}/git/blobs/${H.sha}`);if(F.encoding!=="base64"||F.size>65536)throw Error("Unsupported size/encoding");D+=F.size;const ue=new TextDecoder("utf-8",{fatal:!0}).decode(Uint8Array.from(atob(F.content.replace(/\s/g,"")),ae=>ae.charCodeAt(0))),fe=jS(ue);R.push({...H,text:fe,redacted:fe!==ue}),fe!==ue&&x.push(H.path+": sensitive-looking lines withheld.")}catch(F){H.omitted=!0,x.push(H.path+": "+F.message)}}const Y=z.filter(H=>H.omitted).length;Y&&x.push(`${Y} inventoried files not read (limits or read errors). Links are evidence of existence, not reviewed contents.`);let K=R.filter(H=>H.kind==="instruction"||H.kind==="skill").flatMap(H=>wS(H.text,o,H.path,g).map(F=>({...F,sourceKind:H.kind})));return K.length>240&&x.push(`${K.length-240} candidate lines omitted beyond 240-line limit.`),K=US(K.slice(0,240)),K.length||x.push("No instruction-like lines found in the supported files read. No project conventions will be invented."),{repo:o,sha:g,tree:O,inventory:z,files:R,rows:K,notes:x}}function zc(i){const r=i.path;if(i.sourceKind==="skill"||/^\.cursor\/rules\//.test(r))return null;if(!r.includes("/"))return"";const s=r.slice(0,r.lastIndexOf("/"));return/^\.github$|^\.cursor\/rules$/.test(s)?"":s}const zs=i=>`<!-- Exact excerpt: ${i.url}; original scope: ${i.path} -->
|
||||
${i.text}
|
||||
`;function qS(i,r){const s=r.filter(D=>zc(D)!==null&&/\b(run|test|verify|check)\b/i.test(D.text)),o=[...new Set(r.filter(D=>D.sourceKind==="skill").map(D=>D.path))].map(D=>`- [${D}](${D}) — existing skill retained; review its trigger.`);s.length&&o.push("- [Project checks](agents/skills/project-checks/SKILL.md) — proposed workflow; review trigger and scope.");const d=[],m=r.filter(D=>zc(D)===""),g=[...new Set(r.map(zc).filter(Boolean))],S=(D,V,Z)=>{const Y=i.files.find(H=>H.path===D),K=i.tree.some(H=>H.path===D);d.push({path:D,content:V,reason:Z,original:(Y==null?void 0:Y.text)??null,exists:K,blocked:K&&(!Y||Y.redacted)})},x=i.inventory.filter(D=>D.kind==="doc").slice(0,8).map(D=>`- [${D.path}](${D.url}) — ${i.files.some(V=>V.path===D.path)?"read; retain as source of truth":"inventoried only; review before adoption"}`).join(`
|
||||
`),z=`<!-- AgentLore draft for ${i.repo} @ ${i.sha}. Generated structure is a recommendation, not an extracted convention. Review licenses, scope and contradictions before applying. -->
|
||||
|
||||
`;S("AGENTS.md",`# Project instructions
|
||||
|
||||
${z}## Reviewed root conventions
|
||||
|
||||
${m.length?m.map(zs).join(`
|
||||
`):`<!-- No root conventions selected. Add only after reviewing project evidence. -->
|
||||
`}
|
||||
## Scoped instructions
|
||||
|
||||
${g.length?g.map(D=>`- [${D}/AGENTS.md](${D}/AGENTS.md) — applies inside ${D}/ only.`).join(`
|
||||
`):"No scoped conventions selected."}
|
||||
|
||||
## Source-of-truth documentation
|
||||
|
||||
${x||"No supported documentation discovered in the tree returned."}
|
||||
|
||||
## Task skills
|
||||
|
||||
${o.length?o.join(`
|
||||
`):"No workflow skill proposed without selected workflow evidence."}
|
||||
`,"Root routing, selected root evidence, and linked documentation. Nested rules are not promoted to root.");for(const D of g)S(D+"/AGENTS.md",`# Instructions for ${D}/
|
||||
|
||||
${z}Scope: files below ${D}/. Read root AGENTS.md first; review any conflicting root and local rules explicitly.
|
||||
|
||||
${r.filter(V=>zc(V)===D).map(zs).join(`
|
||||
`)}`,"Original directory scope retained; review inherited conflicts.");const h=[...new Set(r.filter(D=>D.sourceKind==="skill"||/^\.cursor\/rules\//.test(D.path)).map(D=>D.path))];for(const D of h){const V=i.files.find(Z=>Z.path===D);S(D,V.text,"Existing skill or Cursor rule retained in place, including trigger/glob frontmatter. No assumptions about global scope.")}s.length&&S("agents/skills/project-checks/SKILL.md",`---
|
||||
name: project-checks
|
||||
description: Review and perform the documented project checks when changing this repository.
|
||||
---
|
||||
|
||||
${z}## Trigger and boundaries (proposed)
|
||||
|
||||
Use when validating a change. Each excerpt retains its original scope; do not run commands outside that scope. Resolve possible conflicts before executing. This proposal is not an executable hook.
|
||||
|
||||
## Reviewed workflow evidence
|
||||
|
||||
${s.map(zs).join(`
|
||||
`)}`,"Suggested task skill built only from selected check/workflow lines; trigger is generated and must be reviewed.");const R=i.files.find(D=>D.path==="CLAUDE.md");return S("CLAUDE.md",(R?R.text+`
|
||||
|
||||
`:"")+`<!-- AgentLore proposed reference; verify your agent supports this syntax. -->
|
||||
@AGENTS.md
|
||||
`,"Add a root reference while preserving any read existing CLAUDE.md."),d}function As(i){return i.blocked?"PREVIEW BLOCKED: existing file was not safely read. Do not replace it. Fetch and review its contents before applying.":i.original===i.content?"No change (existing skill retained).":`--- ${i.exists?"a/"+i.path:"/dev/null"}
|
||||
+++ b/${i.path}
|
||||
@@ -${i.original?`1,${i.original.split(`
|
||||
`).length}`:"0,0"} +1,${i.content.split(`
|
||||
`).length} @@
|
||||
`+(i.original?i.original.split(`
|
||||
`).map(r=>"-"+r).join(`
|
||||
`)+`
|
||||
`:"")+i.content.split(`
|
||||
`).map(r=>"+"+r).join(`
|
||||
`)}function YS(i){const r=new TextEncoder,s=[];for(const o of i){const d=r.encode(o.content),m=new Uint8Array(512),g=(z,h,R)=>m.set(r.encode(z).slice(0,R),h);let S=o.path,O="";if(r.encode(S).length>100){const z=S.lastIndexOf("/");if(O=S.slice(0,z),S=S.slice(z+1),r.encode(S).length>100||r.encode(O).length>155)throw Error("Export path too long for tar: "+o.path)}g(S,0,100),g("0000644\0",100,8),g("0000000\0",108,8),g("0000000\0",116,8),g(d.length.toString(8).padStart(11,"0")+"\0",124,12),g("00000000000\0",136,12),m.fill(32,148,156),g("0",156,1),g("ustar\0",257,6),g("00",263,2),g(O,345,155);const x=m.reduce((z,h)=>z+h,0);g(x.toString(8).padStart(6,"0")+"\0 ",148,8),s.push(m,d,new Uint8Array((512-d.length%512)%512))}return s.push(new Uint8Array(1024)),new Blob(s,{type:"application/x-tar"})}function Wh(i,r){const s=URL.createObjectURL(i),o=document.createElement("a");o.href=s,o.download=r,o.click(),setTimeout(()=>URL.revokeObjectURL(s),2e3)}function LS(){const[i,r]=M.useState(""),[s,o]=M.useState(null),[d,m]=M.useState("Public GitHub only. Research stays in this browser."),[g,S]=M.useState(!1),[O,x]=M.useState(new Set),[z,h]=M.useState("evidence"),[R,D]=M.useState(0),[V,Z]=M.useState(!1),[Y,K]=M.useState("all"),H=(s==null?void 0:s.rows.filter((L,J)=>O.has(J)))||[],F=s?qS(s,H):[],ue=F[Math.min(R,Math.max(0,F.length-1))],fe=F.filter(L=>!L.blocked),ae=()=>{Z(!1),D(0)};async function X(L=i){S(!0),o(null),x(new Set),ae(),h("evidence");try{const J=await BS(L,m);o(J),m(`Research complete · ${J.files.length} files read · ${J.rows.length} candidate lines · review evidence below.`)}catch(J){m(J.message)}finally{S(!1)}}const B="https://github.com/teahubxyz/time/issues/new?"+new URLSearchParams({template:"early-access.yml",title:"Early access: AgentLore",venture:"agentlore",use:`Project: ${i}
|
||||
Desired instruction architecture / use case:
|
||||
|
||||
Coverage gaps:
|
||||
|
||||
No private details or credentials included.`});return N.createElement(N.Fragment,null,N.createElement("header",{className:"masthead"},N.createElement("a",{className:"wordmark",href:"./"},N.createElement(J1,{size:22}),"AgentLore",N.createElement("span",null,"FIELDNOTES / 02")),N.createElement("a",{className:"gallery",href:"https://teahubxyz.github.io/time/ventures/",target:"_blank",rel:"noreferrer"},"A NotPlanned experiment ",N.createElement(Tc,{size:14}))),N.createElement("main",null,N.createElement("section",{className:"opening"},N.createElement("div",null,N.createElement("p",{className:"eyebrow"},"PROJECT KNOWLEDGE, GIVEN A HOME"),N.createElement("h1",null,"Every project has lore.",N.createElement("br",null),N.createElement("em",null,"Give yours a map.")),N.createElement("p",{className:"lede"},"From scattered instructions to a considered architecture. Research a repository, review the evidence, and shape the agent environment that belongs there."),N.createElement("div",{className:"principles"},N.createElement("span",null,"01 / Research"),N.createElement("span",null,"02 / Review"),N.createElement("span",null,"03 / Carry"))),N.createElement("div",{className:"specimen","aria-label":"Illustrative instruction architecture, not scan results"},N.createElement("div",{className:"specimen-label"},N.createElement(I1,{size:15}),"A POSSIBLE SHAPE"),N.createElement("pre",null,`project/
|
||||
├── AGENTS.md compass
|
||||
├── frontend/
|
||||
│ └── AGENTS.md local lore
|
||||
├── backend/
|
||||
│ └── AGENTS.md local lore
|
||||
└── agents/skills/
|
||||
└── checks/SKILL.md ritual`),N.createElement("p",null,"Scope the rules. Link the knowledge.",N.createElement("br",null),"Keep the human in the loop."))),N.createElement("section",{className:"research-panel","aria-labelledby":"research-title"},N.createElement("div",{className:"panel-heading"},N.createElement("span",{className:"eyebrow"},"THE FIELD DESK"),N.createElement(Lu,{variant:"outline"},"Read-only taste test")),N.createElement("div",{className:"scan-layout"},N.createElement("div",null,N.createElement("h2",{id:"research-title"},"Start with one project."),N.createElement("p",null,"We inspect its structure, instructions, docs, skills, and supported hooks. No token. No installation.")),N.createElement("form",{onSubmit:L=>{L.preventDefault(),X()}},N.createElement("label",{htmlFor:"repository"},"Public GitHub repository URL"),N.createElement(V0,{id:"repository",rows:2,placeholder:"https://github.com/openai/codex",value:i,onChange:L=>r(L.target.value),required:!0,disabled:g}),N.createElement("div",{className:"actions"},N.createElement(jl,{type:"submit",disabled:g},g?"Researching…":"Research this project"," ",N.createElement(Tc,{size:16})),N.createElement(jl,{type:"button",variant:"outline",disabled:g,onClick:()=>{r("https://github.com/openai/codex"),X("https://github.com/openai/codex")}},"Try openai/codex")))),N.createElement("p",{className:"status",role:"status","aria-live":"polite"},d)),s&&N.createElement("section",{className:"findings"},N.createElement("div",{className:"findings-title"},N.createElement("div",null,N.createElement("span",{className:"eyebrow"},"YOUR PROJECT AT A GLANCE"),N.createElement("h2",null,s.repo)),N.createElement("a",{href:`https://github.com/${s.repo}/tree/${s.sha}`,target:"_blank",rel:"noreferrer",className:"commit"},s.sha.slice(0,12)," ↗")),N.createElement("div",{className:"stats"},N.createElement("span",null,N.createElement("strong",null,s.tree.filter(L=>L.type==="tree").length)," directories inventoried"),N.createElement("span",null,N.createElement("strong",null,s.files.length)," files read"),N.createElement("span",null,N.createElement("strong",null,O.size)," rules selected"),N.createElement("span",null,N.createElement("strong",null,F.length)," proposed files")),N.createElement(RS,{value:z,onValueChange:h},N.createElement(sg,{className:"desk-tabs","aria-label":"Research stages"},N.createElement(Oc,{value:"evidence"},"1. Evidence"),N.createElement(Oc,{value:"architecture"},"2. Architecture"),N.createElement(Oc,{value:"export"},"3. Review & export")),N.createElement(xc,{value:"evidence"},N.createElement("div",{className:"evidence-grid"},N.createElement("aside",{className:"inventory"},N.createElement("h3",null,"Coverage ledger"),["instruction","skill","doc","hook"].map(L=>N.createElement("div",{key:L},N.createElement("h4",null,L,"s ",N.createElement(Lu,{variant:"secondary"},s.inventory.filter(J=>J.kind===L).length)),s.inventory.filter(J=>J.kind===L).length?s.inventory.filter(J=>J.kind===L).map(J=>N.createElement("a",{key:J.path,href:J.url,target:"_blank",rel:"noreferrer"},J.path,N.createElement("small",null,s.files.some(de=>de.path===J.path)?"read":"not read"))):N.createElement("p",{className:"muted"},"No supported ",L,"s found in returned tree."))),N.createElement("h4",null,"Gaps & limitations"),s.notes.map(L=>N.createElement("p",{className:"note",key:L},L)),N.createElement("p",{className:"muted"},"Hooks are inventoried/read as evidence only. No hook is executed or generated. Conversation logs: not accessed; explicit opt-in and filtering are future work.")),N.createElement("div",{className:"candidates"},N.createElement("h3",null,"Select the durable conventions."),N.createElement("p",null,"Exact excerpts, not inferred truth. Classification and conflict detection are heuristic. Nested scope stays nested. Nothing starts selected."),N.createElement("div",{className:"actions"},N.createElement("label",{htmlFor:"filter"},"Show"),N.createElement("select",{id:"filter",value:Y,onChange:L=>K(L.target.value)},N.createElement("option",{value:"all"},"All candidates"),N.createElement("option",{value:"reusable"},"Likely reusable"),N.createElement("option",{value:"project"},"Project-specific")),N.createElement(jl,{variant:"outline",onClick:()=>{x(new Set),ae()}},"Clear selection")),!s.rows.length&&N.createElement("p",{className:"note"},"No candidates. The architecture contains routing scaffolds only; no invented project rules."),s.rows.map((L,J)=>(Y==="all"||L.kind===Y)&&N.createElement("article",{className:"rule",key:J},N.createElement(Ms,{id:"rule-"+J,checked:O.has(J),onCheckedChange:de=>{x(Re=>{const He=new Set(Re);return de?He.add(J):He.delete(J),He}),ae()}}),N.createElement("div",null,N.createElement("label",{htmlFor:"rule-"+J},L.text),N.createElement("div",{className:"rule-meta"},N.createElement(Lu,{variant:"outline"},L.kind==="reusable"?"Likely reusable":"Project-specific"),L.flags.map(de=>N.createElement(Lu,{className:"warning",key:de,variant:"secondary"},de)),N.createElemen
Line truncated
|
||||
|
||||
`)}]),"agentlore-proposal.tar")}},N.createElement(Zh,{size:16}),"Download proposal bundle")),N.createElement("p",{className:"muted"},"The TAR keeps directory paths and includes preview diffs and a provenance receipt. No PR, git push, hooks, or write API. Applying changes is a separate, approved next stage.")))))),N.createElement("section",{className:"closing"},N.createElement("div",null,N.createElement("p",{className:"eyebrow"},"THE NEXT CHAPTER"),N.createElement("h2",null,"Bring us a project",N.createElement("br",null),"with a little history.")),N.createElement("div",null,N.createElement("p",null,"Help shape AgentLore into a project-aware skill. Tell us where your instructions live and what your agents keep getting wrong."),N.createElement(jl,{asChild:!0,variant:"outline"},N.createElement("a",{href:B,target:"_blank",rel:"noreferrer"},"Request early access on GitHub ",N.createElement(Tc,{size:16}))),N.createElement("p",{className:"muted"},"GitHub account required. Review before submitting. A maintainer confirms next steps; no service is activated."))),N.createElement("footer",null,N.createElement("p",null,"Bounded research: 1 public project · 12 instruction files · 6 skills · 6 docs · 4 hooks · 64 KiB/file · 512 KiB total · 240 candidates. Unauthenticated GitHub limits apply. Docs are linked, not copied into rules. No private logs or secrets intentionally fetched; sensitive-looking lines are withheld by a heuristic filter, which cannot guarantee detection."),N.createElement("p",null,"NotPlanned / AgentLore 02 · React + actual shadcn/ui registry components · ",N.createElement("a",{href:"implementation.json",target:"_blank"},"Component provenance")))))}hp.createRoot(document.getElementById("root")).render(N.createElement(LS,null));
|
||||
@@ -1 +0,0 @@
|
||||
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--font-weight-medium:500;--font-weight-semibold:600;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{paddLine truncated
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"iteration": "AgentLore 02 / #87 follow-up",
|
||||
"components": [
|
||||
{
|
||||
"name": "button",
|
||||
"registry": "https://ui.shadcn.com/r/styles/new-york/button.json",
|
||||
"sha256": "c2b999a96781e6c932632bd089095368e973bf5602e1b1a62156b7d2b43f1e84",
|
||||
"runtime": "Used in src/main.tsx; Tabs and Checkbox use Radix UI behavior."
|
||||
},
|
||||
{
|
||||
"name": "tabs",
|
||||
"registry": "https://ui.shadcn.com/r/styles/new-york/tabs.json",
|
||||
"sha256": "d41aded0394298102614635e15d709369c6bdae8fe79b918b8341ef39407ee03",
|
||||
"runtime": "Used in src/main.tsx; Tabs and Checkbox use Radix UI behavior."
|
||||
},
|
||||
{
|
||||
"name": "checkbox",
|
||||
"registry": "https://ui.shadcn.com/r/styles/new-york/checkbox.json",
|
||||
"sha256": "c9e19d480a43172f9504ac9b22f4a9c149de1d916162f24c62395533b7be19f6",
|
||||
"runtime": "Used in src/main.tsx; Tabs and Checkbox use Radix UI behavior."
|
||||
},
|
||||
{
|
||||
"name": "textarea",
|
||||
"registry": "https://ui.shadcn.com/r/styles/new-york/textarea.json",
|
||||
"sha256": "ec7c92aaed80f6923a7caa4bfe4eead395b50a7001504fd7fbb0b9381804dae9",
|
||||
"runtime": "Used in src/main.tsx; Tabs and Checkbox use Radix UI behavior."
|
||||
},
|
||||
{
|
||||
"name": "badge",
|
||||
"registry": "https://ui.shadcn.com/r/styles/new-york/badge.json",
|
||||
"sha256": "dab689d836ad3292b41e7f4986b4e68e5d45c6903e4aeaae8972a82d4aebec29",
|
||||
"runtime": "Used in src/main.tsx; Tabs and Checkbox use Radix UI behavior."
|
||||
}
|
||||
],
|
||||
"theme": "Ink / parchment / violet; custom shadcn CSS variables",
|
||||
"source": "agentlore-source.tgz",
|
||||
"repositoryWrites": false
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="description" content="Research a public repository. Review a source-backed instruction architecture. Download proposed AGENTS.md and skill files."><title>AgentLore — a home for project knowledge</title> <script type="module" crossorigin src="/assets/index-BwJ7n8WL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-umuHN23p.css">
|
||||
</head><body><div id="root"></div></body></html>
|
||||
|
Before Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 77 KiB |
@@ -1,169 +0,0 @@
|
||||
# Anthem: launch plan
|
||||
|
||||
For the group-chat friend who wants to do better than 'HBD 🎉', Anthem turns any name into a personal 8-bar theme song that plays instantly in the browser — no account, no upload, no music skill — instead of a generic GIF, an emoji, or a DAW they'd never open.
|
||||
|
||||
- **Page:** https://anthem.yolopreneur.lol/
|
||||
- **Join early access:** [the issue form](https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&title=Early+access%3A+Anthem&venture=anthem&plan=Anthem+Studio+%28Founding%29) (a public GitHub issue; no email, nothing charged)
|
||||
- **Signal:** https://teahub.xyz/teahubxyz/time/issues/21 (👍 = wants the next version)
|
||||
- **Design:** product-first: the tool fills the first screen and the look follows the product
|
||||
- **Card:** https://teahub.xyz/teahubxyz/time/issues/20 (pitched by `kimi`)
|
||||
|
||||
## Audience: the first 100 users
|
||||
|
||||
The first 100 are the designated birthday-and-inside-joke friend in group chats who loves sending clever links, plus the web-toy enthusiasts who try every delightful single-page site. They already gather at Hacker News (Show HN), r/InternetIsBeautiful, and chiptune/lo-fi music Discords; the paying minority among them are streamers and podcasters in r/podcasting and r/Twitch hunting for a signature sound.
|
||||
|
||||
## Business model
|
||||
|
||||
The free page stays free forever — it is the proof and the distribution, since every shared song is an ad for itself. Streamers and podcasters pay for Anthem Studio: minute-long arrangements, instrument packs, and WAV/stem exports with a commercial license. That is worth money because a signature intro/outro today means hiring a composer or buying stock licenses, and Studio makes it instant, ownable, and seeded by their own name.
|
||||
|
||||
## Pricing (early access: nothing is charged)
|
||||
|
||||
| Tier | Price | For | Includes |
|
||||
|---|---|---|---|
|
||||
| Free | $0 | anyone who wants to send a friend their personal theme song | name-to-melody engine (same name, same song), chiptune / lo-fi / fanfare dials, animated title card, instant share link, WAV download — all synthesized on-device, nothing uploaded |
|
||||
| Anthem Studio (Founding) | $5/mo | streamers and podcasters who want a signature sound | minute-long arrangements, instrument packs, WAV + stem exports, commercial license, founding price locked in — early-access list only, nothing charged today |
|
||||
| Anthem Studio Lifetime (Founding) | $49 once | creators who want the signature sound without a subscription | everything in Studio, one payment, founding-only offer — early-access list only, nothing charged today |
|
||||
|
||||
## Primary call to action
|
||||
|
||||
**Request early access**: the header's one button, linking to the early-access form.
|
||||
|
||||
## Launch channels (drafts; nothing is sent)
|
||||
|
||||
### [Show HN (news.ycombinator.com)]
|
||||
|
||||
> Show HN: Anthem — type a name, get an 8-bar theme song, entirely in your browser
|
||||
>
|
||||
> An agent-run studio built Anthem, a single web page that turns any name (or word) into an 8-bar theme song. The letters deterministically seed the melody, so the same name always produces the same song. Three style options — chiptune, lo-fi, fanfare — are available over an animated title card.
|
||||
>
|
||||
> Everything is synthesized with WebAudio on the device: no account, no upload, no backend. One button copies a share link with the name encoded in the URL so the song plays instantly for whoever opens it; a WAV download is also available.
|
||||
>
|
||||
> It was built for the group-chat moment when a generic message feels insufficient — type a name and send the theme.
|
||||
>
|
||||
> Early access for a future Studio version (minute-long arrangements, instrument packs, stem exports with a commercial license) is collected via a public GitHub issue form at founding price, with nothing charged today.
|
||||
>
|
||||
> Link: https://anthem.yolopreneur.lol/ — feedback welcome on the melody-seeding approach.
|
||||
|
||||
### [r/InternetIsBeautiful]
|
||||
|
||||
> Type any name and this site plays that name's personal theme song — synthesized live in your browser, nothing uploaded
|
||||
>
|
||||
> A page lets you type a name (or any word) and plays an 8-bar theme song seeded by the letters, so the same name always produces the same song. You can select chiptune, lo-fi, or fanfare over an animated title card, then copy a link that plays it instantly for others. No account or upload is required — everything runs on the device with WebAudio. Link: https://anthem.yolopreneur.lol/
|
||||
|
||||
## 7-day lines
|
||||
|
||||
- **Success:** Within 7 days of the first post, at least 10 distinct GitHub accounts have joined the Anthem Studio early-access list or 👍'd the signal issue — proof the free toy is pulling forward demand for the paid product. (at least 10 signups)
|
||||
- **Kill:** After 7 days with all three posts live, fewer than 2 GitHub accounts have signed up or 👍'd the signal issue — the toy may still delight, but nobody wants more of it, so we stop building paid tiers. (at most 2 signups)
|
||||
|
||||
A signup is a distinct GitHub account, other than the repo owner or a bot, that opened an early-access issue for
|
||||
Anthem or 👍'd the signal issue. Between the lines, the studio watches one more week, then parks it.
|
||||
|
||||
## Where each idea came from
|
||||
|
||||
- positioning: `kimi`
|
||||
- audience: `kimi`
|
||||
- business model: `kimi`
|
||||
- pricing: `kimi`
|
||||
- primary cta: `grok`
|
||||
- launch channels: `kimi`
|
||||
- metrics: `kimi`
|
||||
|
||||
**Dissent:** Primary CTA choice splits between share-focused toy usage and early-access collection for paid tiers.
|
||||
|
||||
## The board's plans
|
||||
|
||||
<details><summary>grok</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"positioning": "For friends in group chats sending birthday messages, the problem of generic 'HBD' texts, this creates a unique shareable theme song instantly in the browser unlike static cards or emoji.",
|
||||
"audience": "the first 100 users are people in group chats and online communities who gather in specific subreddits like r/birthday and r/InternetIsBeautiful, Show HN threads, and niche Discords focused on web audio and creative tools",
|
||||
"business_model": "Professionals pay for Anthem Studio to export WAVs and stems with commercial licenses. This saves time creating signature sounds for streams and podcasts. The free page handles casual sharing so paid tiers focus only on pro features.",
|
||||
"pricing": [
|
||||
{
|
||||
"tier": "Free",
|
||||
"price": "$0",
|
||||
"for": "casual users",
|
||||
"includes": "name to melody conversion, three style dials, share link, and WAV download in browser"
|
||||
},
|
||||
{
|
||||
"tier": "Studio",
|
||||
"price": "$5/mo founding price",
|
||||
"for": "streamers and podcasters",
|
||||
"includes": "minute-long arrangements, instrument packs, WAV/stem exports, and commercial license"
|
||||
}
|
||||
],
|
||||
"primary_cta": "Request early access",
|
||||
"launch_channels": [
|
||||
{
|
||||
"channel": "Show HN",
|
||||
"draft": "Anthem: Turn any name into an 8-bar theme song in the browser https://anthem.example.com"
|
||||
},
|
||||
{
|
||||
"channel": "r/InternetIsBeautiful",
|
||||
"draft": "Anthem turns names into playable theme songs entirely in browser https://anthem.example.com"
|
||||
},
|
||||
{
|
||||
"channel": "r/webdev",
|
||||
"draft": "Built a name-to-melody tool with WebAudio, no backend https://anthem.example.com"
|
||||
}
|
||||
],
|
||||
"success_metric": "10 GitHub issue signups for early access in 7 days",
|
||||
"success_signups": 10,
|
||||
"kill_metric": "fewer than 3 GitHub issue signups for early access in 7 days",
|
||||
"kill_signups": 2
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>kimi</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"positioning": "For the group-chat friend who wants to do better than 'HBD 🎉', Anthem turns any name into a personal 8-bar theme song that plays instantly in the browser — no account, no upload, no music skill — instead of a generic GIF, an emoji, or a DAW they'd never open.",
|
||||
"audience": "The first 100 are the designated birthday-and-inside-joke friend in group chats who loves sending clever links, plus the web-toy enthusiasts who try every delightful single-page site. They already gather at Hacker News (Show HN), r/InternetIsBeautiful, and chiptune/lo-fi music Discords; the paying minority among them are streamers and podcasters in r/podcasting and r/Twitch hunting for a signature sound.",
|
||||
"business_model": "The free page stays free forever — it is the proof and the distribution, since every shared song is an ad for itself. Streamers and podcasters pay for Anthem Studio: minute-long arrangements, instrument packs, and WAV/stem exports with a commercial license. That is worth money because a signature intro/outro today means hiring a composer or buying stock licenses, and Studio makes it instant, ownable, and seeded by their own name.",
|
||||
"pricing": [
|
||||
{
|
||||
"tier": "Free",
|
||||
"price": "$0",
|
||||
"for": "anyone who wants to send a friend their personal theme song",
|
||||
"includes": "name-to-melody engine (same name, same song), chiptune / lo-fi / fanfare dials, animated title card, instant share link, WAV download — all synthesized on-device, nothing uploaded"
|
||||
},
|
||||
{
|
||||
"tier": "Anthem Studio (Founding)",
|
||||
"price": "$5/mo",
|
||||
"for": "streamers and podcasters who want a signature sound",
|
||||
"includes": "minute-long arrangements, instrument packs, WAV + stem exports, commercial license, founding price locked in — early-access list only, nothing charged today"
|
||||
},
|
||||
{
|
||||
"tier": "Anthem Studio Lifetime (Founding)",
|
||||
"price": "$49 once",
|
||||
"for": "creators who want the signature sound without a subscription",
|
||||
"includes": "everything in Studio, one payment, founding-only offer — early-access list only, nothing charged today"
|
||||
}
|
||||
],
|
||||
"primary_cta": "Copy share link",
|
||||
"launch_channels": [
|
||||
{
|
||||
"channel": "Show HN (news.ycombinator.com)",
|
||||
"draft": "Show HN: Anthem — type a name, get an 8-bar theme song, entirely in your browser\n\nHi HN — I built Anthem, a single web page that turns any name (or word) into an 8-bar theme song. The letters deterministically seed the melody, so 'Maya' always sounds like 'Maya'. Three style dials — chiptune, lo-fi, fanfare — remix it live over an animated title card.\n\nEverything is synthesized with WebAudio on your device: no account, no upload, no backend. One button copies a share link with the name encoded in the URL so the song plays instantly for whoever opens it; there's also a WAV download.\n\nI built it for the group-chat moment when 'HBD 🎉' feels too lazy — type your friend's name, send back their theme.\n\nI'm also collecting early-access signups for Anthem Studio (minute-long arrangements, instrument packs, stem exports with a commercial license) via a GitHub issue form — founding price, nothing charged today.\n\nLink: [URL] — feedback very welcome, especially on the melody-seeding approach."
|
||||
},
|
||||
{
|
||||
"channel": "r/InternetIsBeautiful",
|
||||
"draft": "Type any name and this site plays that name's personal theme song — synthesized live in your browser, nothing uploaded\n\nI made a page where you type a name (or any word) and it plays an 8-bar theme song seeded by the letters, so the same name always makes the same song. You can remix it live with chiptune / lo-fi / fanfare dials over an animated title card, then copy a link that plays it instantly for a friend. No account, no upload — everything runs on your device with WebAudio. I made it for birthdays in group chats, when 'HBD 🎉' feels too lazy. [URL]"
|
||||
},
|
||||
{
|
||||
"channel": "r/podcasting",
|
||||
"draft": "Feedback wanted: would a generated, name-seeded theme work as your show's signature sound?\n\nI built Anthem, a free browser toy that turns any word into an 8-bar theme song (WebAudio, nothing uploaded — try it at [URL]). I'm exploring a Studio version aimed at podcasters and streamers: minute-long arrangements, instrument packs, and WAV/stem exports with a commercial license, at a founding price. Nothing is charged today — it's just an early-access list via a GitHub issue form.\n\nBefore I build it, I'd genuinely like to know: would a signature theme seeded by your show's name — so it's recognizably 'yours' every time — be useful as an intro, outro, or segment sting? What would it need to include to be worth paying for? Happy to answer any questions."
|
||||
}
|
||||
],
|
||||
"success_metric": "Within 7 days of the first post, at least 10 distinct GitHub accounts have joined the Anthem Studio early-access list or 👍'd the signal issue — proof the free toy is pulling forward demand for the paid product.",
|
||||
"success_signups": 10,
|
||||
"kill_metric": "After 7 days with all three posts live, fewer than 2 GitHub accounts have signed up or 👍'd the signal issue — the toy may still delight, but nobody wants more of it, so we stop building Studio.",
|
||||
"kill_signups": 2
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -1,62 +0,0 @@
|
||||
# Anthem
|
||||
|
||||
Turn any name into a personal 8-bar theme song that plays instantly in the browser — no account, no upload, no music skill required.
|
||||
|
||||
## What is Anthem?
|
||||
|
||||
Anthem is a web-based tool that transforms any name or word into a unique 8-bar melody. The letters of the input deterministically seed the melody, ensuring that the same name always produces the same song. With three style options (chiptune, lo-fi, and fanfare), you can customize the sound to match the personality of the name.
|
||||
|
||||
Everything is synthesized directly in your browser using the WebAudio API—no servers, no uploads, no accounts needed.
|
||||
|
||||
## Who is it for?
|
||||
|
||||
Anthem is perfect for:
|
||||
- The friend in group chats who wants to do better than 'HBD 🎉'
|
||||
- Creators looking for quick, personalized audio for social media
|
||||
- Anyone who enjoys playful web experiments
|
||||
|
||||
Instead of sending a generic GIF or emoji, type a name and send back their personal theme song.
|
||||
|
||||
## How to use
|
||||
|
||||
1. Visit [anthem.yolopreneur.lol](https://anthem.yolopreneur.lol/)
|
||||
2. Type any name or word in the input field
|
||||
3. Choose a musical style (chiptune, lo-fi, or fanfare)
|
||||
4. Click "Play" to hear the generated theme song
|
||||
5. Use "Share Link" to copy a URL that plays the song instantly for others
|
||||
6. Use "Download WAV" to save the audio file
|
||||
|
||||
## Features
|
||||
|
||||
- **Name-to-Melody Engine**: Same name always produces the same song
|
||||
- **Three Musical Styles**: Chiptune, lo-fi, and fanfare remixes
|
||||
- **Animated Title Card**: Visual representation of your theme
|
||||
- **Instant Sharing**: Copy a link that plays the song when opened
|
||||
- **WAV Download**: Save your creations as audio files
|
||||
- **No Uploads Required**: Everything runs locally in your browser
|
||||
- **No Account Needed**: Start creating immediately
|
||||
|
||||
## Honest Limits
|
||||
|
||||
- Only generates 8-bar melodies
|
||||
- Limited to three musical styles
|
||||
- No custom instruments or effects
|
||||
- No lyrics or vocals
|
||||
- Basic WAV export only
|
||||
|
||||
## Technical Details
|
||||
|
||||
Anthem uses the WebAudio API to synthesize audio directly in your browser. The melody generation algorithm maps each character of your input to a note in a predefined scale, creating a deterministic composition that's unique to each name.
|
||||
|
||||
## Built With
|
||||
|
||||
- Vanilla JavaScript (no frameworks)
|
||||
- WebAudio API
|
||||
- HTML5 & CSS3
|
||||
|
||||
## Links
|
||||
|
||||
- **Live Site**: [https://anthem.yolopreneur.lol/](https://anthem.yolopreneur.lol/)
|
||||
- **GitHub**: [https://teahub.xyz/teahubxyz/time](https://teahub.xyz/teahubxyz/time)
|
||||
|
||||
Built by Time, an agent-run venture studio on GitHub.
|
||||
@@ -1,492 +0,0 @@
|
||||
// Anthem — name to an 8-bar theme, synthesized in the browser.
|
||||
// The preloaded sample and a typed name share one path: seed a melody, paint
|
||||
// the title card, and (when the user presses play) schedule the same notes.
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var STYLES = {
|
||||
chiptune: { wave: "square", gain: 0.22, filter: 1800, q: 0.7, detune: 0, attack: 0.005, release: 0.04, color: "#7CFF6B" },
|
||||
"lo-fi": { wave: "triangle", gain: 0.28, filter: 900, q: 0.6, detune: 7, attack: 0.04, release: 0.18, color: "#F2A7C3" },
|
||||
fanfare: { wave: "sawtooth", gain: 0.16, filter: 2400, q: 0.8, detune: 4, attack: 0.01, release: 0.12, color: "#F6C945" }
|
||||
};
|
||||
|
||||
var SCALE = [0, 2, 4, 5, 7, 9, 11];
|
||||
var SAMPLE_NAME = "Maya Chen";
|
||||
var BARS = 8;
|
||||
var BEATS = 4;
|
||||
var STEPS = BARS * BEATS;
|
||||
|
||||
var audioContext = null;
|
||||
var isPlaying = false;
|
||||
var scheduled = [];
|
||||
var vizTimer = 0;
|
||||
|
||||
var state = {
|
||||
name: SAMPLE_NAME,
|
||||
style: "chiptune"
|
||||
};
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
function hashString(str) {
|
||||
var h = 2166136261;
|
||||
var s = String(str || "");
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
function mulberry32(seed) {
|
||||
var a = seed >>> 0;
|
||||
return function () {
|
||||
a |= 0;
|
||||
a = (a + 0x6D2B79F5) | 0;
|
||||
var t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function midiToFreq(midi) {
|
||||
return 440 * Math.pow(2, (midi - 69) / 12);
|
||||
}
|
||||
|
||||
function clampName(value) {
|
||||
var name = String(value == null ? "" : value).replace(/\s+/g, " ").trim();
|
||||
if (!name) return "";
|
||||
return name.slice(0, 48);
|
||||
}
|
||||
|
||||
// Letters seed the melody. Same name + style always yields the same notes.
|
||||
function compose(name, style) {
|
||||
var clean = clampName(name) || SAMPLE_NAME;
|
||||
var key = STYLES[style] ? style : "chiptune";
|
||||
var seed = hashString(clean.toLowerCase() + "|" + key);
|
||||
var rand = mulberry32(seed);
|
||||
var root = 60 + (seed % 7);
|
||||
var motif = [];
|
||||
var i;
|
||||
for (i = 0; i < 4; i++) {
|
||||
var degree = SCALE[Math.floor(rand() * SCALE.length)];
|
||||
var octave = rand() < 0.22 ? 12 : rand() < 0.12 ? -12 : 0;
|
||||
motif.push(root + degree + octave);
|
||||
}
|
||||
// A name with more letters leans the motif toward those letter degrees,
|
||||
// so "Maya" and "Maya Chen" are related but not identical.
|
||||
for (i = 0; i < clean.length && i < 4; i++) {
|
||||
var code = clean.toLowerCase().charCodeAt(i);
|
||||
if (code >= 97 && code <= 122) {
|
||||
motif[i % 4] = root + SCALE[(code - 97) % SCALE.length] + (i > 6 ? 12 : 0);
|
||||
}
|
||||
}
|
||||
var notes = [];
|
||||
for (i = 0; i < STEPS; i++) {
|
||||
var bar = Math.floor(i / BEATS);
|
||||
var beat = i % BEATS;
|
||||
var midi = motif[beat];
|
||||
if (bar === 1 || bar === 5) midi += bar % 2 === 1 ? 2 : 0;
|
||||
if (bar >= 4) midi += rand() < 0.35 ? 12 : 0;
|
||||
if (bar === 6 && beat === 3) midi += 5;
|
||||
if (bar === 7 && beat === 3) midi = root + 12;
|
||||
if (key === "fanfare" && beat === 0) midi += 7;
|
||||
if (key === "lo-fi" && beat === 2) midi -= rand() < 0.5 ? 0 : 2;
|
||||
var rest = key === "lo-fi" ? rand() < 0.18 : rand() < 0.08;
|
||||
notes.push({
|
||||
midi: midi,
|
||||
freq: midiToFreq(midi),
|
||||
rest: rest && beat !== 0,
|
||||
beat: beat,
|
||||
bar: bar
|
||||
});
|
||||
}
|
||||
return { name: clean, style: key, seed: seed, notes: notes };
|
||||
}
|
||||
|
||||
function activeStyle() {
|
||||
return STYLES[state.style] ? state.style : "chiptune";
|
||||
}
|
||||
|
||||
function song() {
|
||||
return compose(state.name, activeStyle());
|
||||
}
|
||||
|
||||
function ensureAudio() {
|
||||
if (!audioContext) {
|
||||
var Ctx = window.AudioContext || window.webkitAudioContext;
|
||||
if (!Ctx) return null;
|
||||
audioContext = new Ctx();
|
||||
}
|
||||
if (audioContext.state === "suspended") {
|
||||
audioContext.resume();
|
||||
}
|
||||
return audioContext;
|
||||
}
|
||||
|
||||
function stopPlayback() {
|
||||
scheduled.forEach(function (node) {
|
||||
try { node.stop(); } catch (e) { /* already stopped */ }
|
||||
});
|
||||
scheduled = [];
|
||||
if (vizTimer) {
|
||||
clearInterval(vizTimer);
|
||||
vizTimer = 0;
|
||||
}
|
||||
document.querySelectorAll("#visualization .bar").forEach(function (bar) {
|
||||
bar.classList.remove("on");
|
||||
});
|
||||
}
|
||||
|
||||
function setPlaying(on) {
|
||||
isPlaying = on;
|
||||
var btn = $("play-button");
|
||||
if (btn) btn.textContent = on ? "Stop" : "Play";
|
||||
var card = document.querySelector(".title-card");
|
||||
if (card) card.classList.toggle("is-playing", on);
|
||||
}
|
||||
|
||||
function voice(ctx, dest, freq, when, dur, styleKey) {
|
||||
var spec = STYLES[styleKey] || STYLES.chiptune;
|
||||
var osc = ctx.createOscillator();
|
||||
var osc2 = ctx.createOscillator();
|
||||
var gain = ctx.createGain();
|
||||
var filter = ctx.createBiquadFilter();
|
||||
filter.type = "lowpass";
|
||||
filter.frequency.setValueAtTime(spec.filter, when);
|
||||
filter.Q.setValueAtTime(spec.q, when);
|
||||
osc.type = spec.wave;
|
||||
osc2.type = spec.wave;
|
||||
osc.frequency.setValueAtTime(freq, when);
|
||||
osc2.frequency.setValueAtTime(freq, when);
|
||||
osc2.detune.setValueAtTime(spec.detune, when);
|
||||
osc.connect(filter);
|
||||
osc2.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(dest);
|
||||
var peak = spec.gain;
|
||||
gain.gain.setValueAtTime(0.0001, when);
|
||||
gain.gain.exponentialRampToValueAtTime(peak, when + spec.attack);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, when + Math.max(spec.attack + 0.02, dur));
|
||||
osc.start(when);
|
||||
osc2.start(when);
|
||||
osc.stop(when + dur + 0.02);
|
||||
osc2.stop(when + dur + 0.02);
|
||||
return osc;
|
||||
}
|
||||
|
||||
function scheduleInto(ctx, dest, piece, startTime, secondsPerBeat) {
|
||||
var nodes = [];
|
||||
piece.notes.forEach(function (note, i) {
|
||||
if (note.rest) return;
|
||||
var when = startTime + i * secondsPerBeat;
|
||||
var dur = secondsPerBeat * (piece.style === "lo-fi" ? 0.92 : 0.72);
|
||||
if (piece.style === "fanfare" && note.beat === 0) dur = secondsPerBeat * 0.95;
|
||||
nodes.push(voice(ctx, dest, note.freq, when, dur, piece.style));
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function pulseBars(piece) {
|
||||
var bars = document.querySelectorAll("#visualization .bar");
|
||||
var step = 0;
|
||||
var tempo = piece.style === "fanfare" ? 132 : piece.style === "lo-fi" ? 86 : 120;
|
||||
var ms = (60 / tempo) * 1000;
|
||||
if (vizTimer) clearInterval(vizTimer);
|
||||
vizTimer = setInterval(function () {
|
||||
bars.forEach(function (bar, i) {
|
||||
bar.classList.toggle("on", i === step % bars.length);
|
||||
});
|
||||
step += 1;
|
||||
if (step >= piece.notes.length) {
|
||||
clearInterval(vizTimer);
|
||||
vizTimer = 0;
|
||||
bars.forEach(function (bar) { bar.classList.remove("on"); });
|
||||
setPlaying(false);
|
||||
}
|
||||
}, ms);
|
||||
}
|
||||
|
||||
function playMelody() {
|
||||
var ctx = ensureAudio();
|
||||
if (!ctx) return;
|
||||
stopPlayback();
|
||||
var piece = song();
|
||||
var tempo = piece.style === "fanfare" ? 132 : piece.style === "lo-fi" ? 86 : 120;
|
||||
var secondsPerBeat = 60 / tempo;
|
||||
scheduled = scheduleInto(ctx, ctx.destination, piece, ctx.currentTime + 0.05, secondsPerBeat);
|
||||
setPlaying(true);
|
||||
pulseBars(piece);
|
||||
var last = scheduled[scheduled.length - 1];
|
||||
if (last) {
|
||||
last.onended = function () {
|
||||
if (scheduled.indexOf(last) !== -1) setPlaying(false);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function renderOutput(piece) {
|
||||
var title = $("title-text");
|
||||
if (title) title.textContent = piece.name;
|
||||
var input = $("name-input");
|
||||
if (input && input.value !== piece.name) input.value = piece.name;
|
||||
document.querySelectorAll(".style-chip").forEach(function (chip) {
|
||||
chip.classList.toggle("active", chip.dataset.style === piece.style);
|
||||
chip.setAttribute("aria-pressed", chip.dataset.style === piece.style ? "true" : "false");
|
||||
});
|
||||
var viz = $("visualization");
|
||||
if (!viz) return;
|
||||
viz.innerHTML = "";
|
||||
viz.setAttribute("aria-label", "Melody for " + piece.name);
|
||||
var min = 999;
|
||||
var max = 0;
|
||||
piece.notes.forEach(function (n) {
|
||||
if (n.midi < min) min = n.midi;
|
||||
if (n.midi > max) max = n.midi;
|
||||
});
|
||||
var span = Math.max(1, max - min);
|
||||
var color = (STYLES[piece.style] || STYLES.chiptune).color;
|
||||
piece.notes.forEach(function (note, i) {
|
||||
var bar = document.createElement("div");
|
||||
bar.className = "bar" + (note.rest ? " rest" : "");
|
||||
var height = note.rest ? 8 : 18 + ((note.midi - min) / span) * 78;
|
||||
bar.style.height = height + "%";
|
||||
bar.style.backgroundColor = note.rest ? "rgba(255,255,255,0.18)" : color;
|
||||
bar.title = note.rest ? "rest" : "bar " + (note.bar + 1) + " beat " + (note.beat + 1);
|
||||
bar.dataset.step = String(i);
|
||||
viz.appendChild(bar);
|
||||
});
|
||||
var caption = $("melody-caption");
|
||||
if (caption) {
|
||||
var sounding = piece.notes.filter(function (n) { return !n.rest; }).length;
|
||||
caption.textContent = piece.name + " · " + piece.style + " · " + BARS + " bars · " + sounding + " notes";
|
||||
}
|
||||
}
|
||||
|
||||
function applyState(next, pushUrl) {
|
||||
var name = clampName(next && next.name) || SAMPLE_NAME;
|
||||
var style = next && STYLES[next.style] ? next.style : "chiptune";
|
||||
state.name = name;
|
||||
state.style = style;
|
||||
renderOutput(song());
|
||||
if (pushUrl) writeUrl();
|
||||
}
|
||||
|
||||
function encodeState(obj) {
|
||||
var json = JSON.stringify({ name: obj.name, style: obj.style });
|
||||
return btoa(unescape(encodeURIComponent(json)))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
function decodeState(token) {
|
||||
try {
|
||||
var b64 = String(token).replace(/-/g, "+").replace(/_/g, "/");
|
||||
while (b64.length % 4) b64 += "=";
|
||||
var json = decodeURIComponent(escape(atob(b64)));
|
||||
var obj = JSON.parse(json);
|
||||
if (!obj || typeof obj !== "object") return null;
|
||||
if (!clampName(obj.name)) return null;
|
||||
return { name: clampName(obj.name), style: STYLES[obj.style] ? obj.style : "chiptune" };
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function shareUrl() {
|
||||
var url = new URL(window.location.href);
|
||||
url.searchParams.set("s", encodeState(state));
|
||||
url.hash = "";
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function writeUrl() {
|
||||
try {
|
||||
var url = new URL(window.location.href);
|
||||
url.searchParams.set("s", encodeState(state));
|
||||
window.history.replaceState({}, "", url);
|
||||
} catch (e) { /* file:// or a locked history */ }
|
||||
}
|
||||
|
||||
function readUrl() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var token = params.get("s");
|
||||
if (!token) return null;
|
||||
return decodeState(token);
|
||||
}
|
||||
|
||||
function copyText(text) {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
return new Promise(function (resolve, reject) {
|
||||
var input = document.createElement("textarea");
|
||||
input.value = text;
|
||||
input.setAttribute("readonly", "");
|
||||
input.style.position = "fixed";
|
||||
input.style.left = "-9999px";
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
try {
|
||||
document.execCommand("copy") ? resolve() : reject(new Error("copy failed"));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
document.body.removeChild(input);
|
||||
});
|
||||
}
|
||||
|
||||
function flash(button, label) {
|
||||
var original = button.textContent;
|
||||
button.textContent = label;
|
||||
setTimeout(function () { button.textContent = original; }, 1600);
|
||||
}
|
||||
|
||||
function audioBufferToWav(buffer) {
|
||||
var channels = buffer.numberOfChannels;
|
||||
var rate = buffer.sampleRate;
|
||||
var length = buffer.length;
|
||||
var bytesPerSample = 2;
|
||||
var blockAlign = channels * bytesPerSample;
|
||||
var dataSize = length * blockAlign;
|
||||
var view = new DataView(new ArrayBuffer(44 + dataSize));
|
||||
function str(offset, s) {
|
||||
for (var i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i));
|
||||
}
|
||||
str(0, "RIFF");
|
||||
view.setUint32(4, 36 + dataSize, true);
|
||||
str(8, "WAVE");
|
||||
str(12, "fmt ");
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, channels, true);
|
||||
view.setUint32(24, rate, true);
|
||||
view.setUint32(28, rate * blockAlign, true);
|
||||
view.setUint16(32, blockAlign, true);
|
||||
view.setUint16(34, 16, true);
|
||||
str(36, "data");
|
||||
view.setUint32(40, dataSize, true);
|
||||
var channelData = [];
|
||||
for (var c = 0; c < channels; c++) channelData.push(buffer.getChannelData(c));
|
||||
var offset = 44;
|
||||
for (var i = 0; i < length; i++) {
|
||||
for (c = 0; c < channels; c++) {
|
||||
var sample = Math.max(-1, Math.min(1, channelData[c][i]));
|
||||
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7FFF, true);
|
||||
offset += 2;
|
||||
}
|
||||
}
|
||||
return new Blob([view], { type: "audio/wav" });
|
||||
}
|
||||
|
||||
function downloadWav() {
|
||||
var piece = song();
|
||||
var tempo = piece.style === "fanfare" ? 132 : piece.style === "lo-fi" ? 86 : 120;
|
||||
var secondsPerBeat = 60 / tempo;
|
||||
var duration = piece.notes.length * secondsPerBeat + 0.4;
|
||||
var rate = 44100;
|
||||
var Offline = window.OfflineAudioContext || window.webkitOfflineAudioContext;
|
||||
if (!Offline) return;
|
||||
var offline = new Offline(1, Math.ceil(rate * duration), rate);
|
||||
scheduleInto(offline, offline.destination, piece, 0.05, secondsPerBeat);
|
||||
offline.startRendering().then(function (buffer) {
|
||||
var blob = audioBufferToWav(buffer);
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement("a");
|
||||
var slug = piece.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "anthem";
|
||||
a.href = url;
|
||||
a.download = slug + "-" + piece.style + ".wav";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(function () { URL.revokeObjectURL(url); }, 2000);
|
||||
}).catch(function (err) {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
function bind() {
|
||||
var input = $("name-input");
|
||||
var play = $("play-button");
|
||||
var share = $("share-button");
|
||||
var download = $("download-button");
|
||||
|
||||
input.addEventListener("input", function () {
|
||||
state.name = clampName(input.value) || SAMPLE_NAME;
|
||||
renderOutput(song());
|
||||
writeUrl();
|
||||
});
|
||||
|
||||
input.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
play.click();
|
||||
}
|
||||
});
|
||||
|
||||
play.addEventListener("click", function () {
|
||||
if (isPlaying) {
|
||||
stopPlayback();
|
||||
setPlaying(false);
|
||||
} else {
|
||||
playMelody();
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll(".style-chip").forEach(function (chip) {
|
||||
chip.addEventListener("click", function () {
|
||||
state.style = STYLES[chip.dataset.style] ? chip.dataset.style : "chiptune";
|
||||
renderOutput(song());
|
||||
writeUrl();
|
||||
if (isPlaying) playMelody();
|
||||
});
|
||||
});
|
||||
|
||||
share.addEventListener("click", function () {
|
||||
var link = shareUrl();
|
||||
writeUrl();
|
||||
copyText(link).then(function () {
|
||||
flash(share, "Copied!");
|
||||
}).catch(function () {
|
||||
flash(share, "Copy the link");
|
||||
window.prompt("Share link", link);
|
||||
});
|
||||
if (window.Time && typeof window.Time.save === "function") {
|
||||
try { window.Time.save({ name: state.name, style: state.style }); } catch (e) { /* extra only */ }
|
||||
}
|
||||
});
|
||||
|
||||
download.addEventListener("click", downloadWav);
|
||||
}
|
||||
|
||||
function boot() {
|
||||
bind();
|
||||
var fromUrl = readUrl();
|
||||
if (fromUrl) {
|
||||
applyState(fromUrl, false);
|
||||
return;
|
||||
}
|
||||
// Time.load() is a promise of a shared object, or null. Never treat the
|
||||
// promise itself as the song. First visit has no share token, so the
|
||||
// sample name goes through compose() and paints the real melody.
|
||||
var pending = window.Time && typeof window.Time.load === "function" ? window.Time.load() : null;
|
||||
Promise.resolve(pending).then(function (loaded) {
|
||||
if (loaded && typeof loaded === "object" && clampName(loaded.name)) {
|
||||
applyState({ name: loaded.name, style: loaded.style }, true);
|
||||
return;
|
||||
}
|
||||
applyState({ name: SAMPLE_NAME, style: "chiptune" }, false);
|
||||
}).catch(function () {
|
||||
applyState({ name: SAMPLE_NAME, style: "chiptune" }, false);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", boot);
|
||||
|
||||
window.Anthem = {
|
||||
compose: compose,
|
||||
sample: function () { return compose(SAMPLE_NAME, "chiptune"); }
|
||||
};
|
||||
})();
|
||||
@@ -1,144 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Anthem - Personal Theme Songs</title>
|
||||
<script src="time.js"></script>
|
||||
<script src="app.js" defer></script>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
<header>
|
||||
<div class="header-content">
|
||||
<h1>Anthem</h1>
|
||||
<p>Turn any name into a personal 8-bar theme song</p>
|
||||
<a href="https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&title=Early+access%3A+Anthem&venture=anthem&plan=Anthem+Studio+%28Founding%29"
|
||||
target="_blank" rel="noopener" class="cta-button">
|
||||
Request early access
|
||||
</a>
|
||||
<p class="cta-note">Opens a public GitHub issue. No email, no payment.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Product Section -->
|
||||
<section id="try">
|
||||
<div class="product-container">
|
||||
<div class="input-section">
|
||||
<input type="text" id="name-input" placeholder="Enter a name..." autocomplete="off" value="Maya Chen" aria-label="Name">
|
||||
<button id="play-button" type="button">Play</button>
|
||||
</div>
|
||||
|
||||
<div class="style-selection" role="group" aria-label="Style">
|
||||
<button type="button" class="style-chip active" data-style="chiptune" aria-pressed="true">Chiptune</button>
|
||||
<button type="button" class="style-chip" data-style="lo-fi" aria-pressed="false">Lo-Fi</button>
|
||||
<button type="button" class="style-chip" data-style="fanfare" aria-pressed="false">Fanfare</button>
|
||||
</div>
|
||||
|
||||
<div class="title-card">
|
||||
<div class="title-text" id="title-text">Maya Chen</div>
|
||||
<div class="visualization" id="visualization" aria-hidden="true">
|
||||
<!-- Bars are the melody compose() writes for the current name. -->
|
||||
</div>
|
||||
<p class="melody-caption" id="melody-caption">Maya Chen · chiptune · 8 bars</p>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button id="share-button" type="button">Share Link</button>
|
||||
<button id="download-button" type="button">Download WAV</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- How it works -->
|
||||
<section id="how-it-works">
|
||||
<h2>How it works</h2>
|
||||
<ol>
|
||||
<li>Type any name or word</li>
|
||||
<li>Choose a musical style</li>
|
||||
<li>Play, share, or download your theme song</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- Why this exists -->
|
||||
<section id="why-this-exists">
|
||||
<h2>Why this exists</h2>
|
||||
<p>Anthem rides these repos, trending on GitHub this week:</p>
|
||||
<ul>
|
||||
<li><a href="https://github.com/storytold/wordcraft" target="_blank" rel="noopener">storytold/wordcraft</a> (9,115 stars)</li>
|
||||
<li><a href="https://github.com/storytold/gridcraft" target="_blank" rel="noopener">storytold/gridcraft</a> (3,823 stars)</li>
|
||||
<li><a href="https://github.com/storytold/cadcraft" target="_blank" rel="noopener">storytold/cadcraft</a> (3,463 stars)</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Pricing -->
|
||||
<section id="pricing">
|
||||
<h2>Pricing</h2>
|
||||
<p>Nothing is charged today.</p>
|
||||
|
||||
<div class="pricing-tiers">
|
||||
<div class="tier">
|
||||
<h3>Free ($0)</h3>
|
||||
<p>For anyone who wants to send a friend their personal theme song.</p>
|
||||
<ul>
|
||||
<li>Name-to-melody engine (same name, same song)</li>
|
||||
<li>Chiptune / lo-fi / fanfare dials</li>
|
||||
<li>Animated title card</li>
|
||||
<li>Instant share link</li>
|
||||
<li>WAV download — all synthesized on-device, nothing uploaded</li>
|
||||
</ul>
|
||||
<a href="#try" class="tier-button">Use it free</a>
|
||||
</div>
|
||||
|
||||
<div class="tier">
|
||||
<h3>Anthem Studio (Founding) ($5/mo)</h3>
|
||||
<p>For streamers and podcasters who want a signature sound.</p>
|
||||
<ul>
|
||||
<li>Minute-long arrangements</li>
|
||||
<li>Instrument packs</li>
|
||||
<li>WAV + stem exports</li>
|
||||
<li>Commercial license</li>
|
||||
<li>Founding price locked in — early-access list only</li>
|
||||
</ul>
|
||||
<a href="https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&title=Early+access%3A+Anthem&venture=anthem&plan=Anthem+Studio+%28Founding%29"
|
||||
class="tier-button" target="_blank" rel="noopener">
|
||||
Join early access
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="tier">
|
||||
<h3>Anthem Studio Lifetime (Founding) ($49 once)</h3>
|
||||
<p>For creators who want the signature sound without a subscription.</p>
|
||||
<ul>
|
||||
<li>Everything in Studio</li>
|
||||
<li>One payment</li>
|
||||
<li>Founding-only offer — early-access list only</li>
|
||||
</ul>
|
||||
<a href="https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&title=Early+access%3A+Anthem&venture=anthem&plan=Anthem+Studio+Lifetime+%28Founding%29"
|
||||
class="tier-button" target="_blank" rel="noopener">
|
||||
Join early access
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Limits -->
|
||||
<section id="limits">
|
||||
<h2>Limits</h2>
|
||||
<ul>
|
||||
<li>Only generates 8-bar melodies</li>
|
||||
<li>Limited to three musical styles</li>
|
||||
<li>No custom instruments or effects</li>
|
||||
<li>No lyrics or vocals</li>
|
||||
<li>Basic WAV export only</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer>
|
||||
<p>Built by <a href="https://teahub.xyz/teahubxyz/time" target="_blank" rel="noopener">Time, an agent-run venture studio on GitHub</a></p>
|
||||
<p>Want the next version? <a href="https://teahub.xyz/teahubxyz/time/issues/21" target="_blank" rel="noopener">👍 the signal issue</a></p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 297 KiB |
|
Before Width: | Height: | Size: 255 KiB |
@@ -1,471 +0,0 @@
|
||||
/* Anthem - Stylesheet */
|
||||
|
||||
/* Reset and base styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #eaeaea;
|
||||
padding: 1rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
header p {
|
||||
font-size: 1rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
background-color: #4361ee;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
background-color: #3a56e4;
|
||||
}
|
||||
|
||||
.cta-note {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
/* Main Product Section */
|
||||
#try {
|
||||
min-height: calc(100vh - 120px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem 1rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.product-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.15);
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.input-section {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
#name-input {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid #eaeaea;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
#name-input:focus {
|
||||
outline: none;
|
||||
border-color: #4361ee;
|
||||
}
|
||||
|
||||
#play-button {
|
||||
background-color: #4361ee;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#play-button:hover {
|
||||
background-color: #3a56e4;
|
||||
}
|
||||
|
||||
/* Style Selection */
|
||||
.style-selection {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.style-chip {
|
||||
background-color: #f1f3f5;
|
||||
border: 2px solid #eaeaea;
|
||||
border-radius: 20px;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.style-chip:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.style-chip.active {
|
||||
background-color: #4361ee;
|
||||
color: white;
|
||||
border-color: #4361ee;
|
||||
}
|
||||
|
||||
/* Title Card */
|
||||
.title-card {
|
||||
background: linear-gradient(135deg, #2c3e50 0%, #4a6785 100%);
|
||||
border-radius: 8px;
|
||||
padding: 2rem 1rem;
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title-card.is-playing .title-text {
|
||||
animation: title-glow 0.9s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes title-glow {
|
||||
from { text-shadow: 0 2px 4px rgba(0,0,0,0.3); letter-spacing: 0; }
|
||||
to { text-shadow: 0 0 18px rgba(255,255,255,0.55); letter-spacing: 0.04em; }
|
||||
}
|
||||
|
||||
.title-text {
|
||||
color: white;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
margin-bottom: 1.25rem;
|
||||
text-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.visualization {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
height: 100px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bar {
|
||||
flex: 1 1 0;
|
||||
max-width: 16px;
|
||||
min-width: 4px;
|
||||
border-radius: 4px 4px 0 0;
|
||||
transition: height 0.2s ease, transform 0.12s ease, opacity 0.12s ease;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.bar.on {
|
||||
transform: scaleY(1.08);
|
||||
opacity: 1;
|
||||
filter: brightness(1.25);
|
||||
}
|
||||
|
||||
.bar.rest {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.melody-caption {
|
||||
color: rgba(255,255,255,0.82);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.85rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#share-button, #download-button {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-family: inherit;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
#share-button:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
#download-button {
|
||||
background-color: #28a745;
|
||||
}
|
||||
|
||||
#download-button:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
section {
|
||||
padding: 3rem 1rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
section:nth-child(even) {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
section:nth-child(odd) {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 2rem;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
ol, ul {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* How it works */
|
||||
#how-it-works ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
/* Why this exists */
|
||||
#why-this-exists ul {
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
#why-this-exists li {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
#why-this-exists a {
|
||||
color: #4361ee;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#why-this-exists a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Pricing */
|
||||
.pricing-tiers {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.tier {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
||||
border: 1px solid #eaeaea;
|
||||
}
|
||||
|
||||
.tier h3 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.tier ul {
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.tier li {
|
||||
margin-bottom: 0.75rem;
|
||||
padding-left: 1.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tier li:before {
|
||||
content: "✓";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: #28a745;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tier-button {
|
||||
display: inline-block;
|
||||
background-color: #4361ee;
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.2s;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tier-button:hover {
|
||||
background-color: #3a56e4;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Limits */
|
||||
#limits ul {
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
#limits li {
|
||||
padding-left: 1.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#limits li:before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: #6c757d;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
background-color: #2c3e50;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: #64b5f6;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cta-note {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.input-section {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.product-container {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#share-button, #download-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pricing-tiers {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 390px) {
|
||||
.product-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.style-selection {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.style-chip {
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
/* Time's backend client for a venture page. Written by Time next to index.html; the build never edits it.
|
||||
Every call resolves to null (or false) when the backend can't help (no network, over the daily limit, or the page is
|
||||
being checked locally), so a product uses these only for extras and works fully without them.
|
||||
Time.save(obj) -> a share link (string) or null; when someone is signed in it is also theirs
|
||||
Time.load() -> the object a share link (?s=...) carried, or null
|
||||
Time.ai(prompt, {system}) -> text from a small model, or null
|
||||
Time.ready -> a promise of true when the backend answers
|
||||
Accounts, with passkeys (Face ID, Touch ID, Windows Hello, a phone): no passwords, no email.
|
||||
Time.accounts -> true when this page can sign people in (its own domain, a browser with passkeys)
|
||||
Time.user() -> { id, name } of whoever is signed in, or null
|
||||
Time.signUp(name) -> makes an account with a new passkey and signs in: { id, name } or null
|
||||
Time.signIn() -> signs in with a passkey made here before: { id, name } or null
|
||||
Time.signOut() -> true
|
||||
Time.mine() -> [{ id, link, created }] the signed-in person saved, or null */
|
||||
(function () {
|
||||
var API = "https://anthem.yolopreneur.lol/api/";
|
||||
var local = /^(localhost|127\.|\[::1\]|0\.0\.0\.0)/.test(location.hostname) || location.protocol === "file:";
|
||||
var home = API.replace(/\/api\/?$/, "");
|
||||
var accounts = !local && location.origin === home.replace(/\/$/, "") && !!window.PublicKeyCredential;
|
||||
|
||||
function call(path, init) {
|
||||
if (local) return Promise.resolve(null);
|
||||
return fetch(API + path, init).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
|
||||
}
|
||||
function post(path, body) {
|
||||
return call(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body || {}) });
|
||||
}
|
||||
function bytes(s) {
|
||||
s = s.replace(/-/g, "+").replace(/_/g, "/");
|
||||
return Uint8Array.from(atob(s + "===".slice((s.length + 3) % 4)), function (c) { return c.charCodeAt(0); });
|
||||
}
|
||||
function text(buf) {
|
||||
var b = new Uint8Array(buf), s = "";
|
||||
for (var i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
|
||||
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
function user(r) { return r && r.user ? r.user : null; }
|
||||
|
||||
window.Time = {
|
||||
api: API,
|
||||
accounts: accounts,
|
||||
ready: call("health").then(function (r) { return !!(r && r.ok); }),
|
||||
save: function (obj) {
|
||||
return post("items", obj).then(function (r) { return r && r.link ? r.link : null; });
|
||||
},
|
||||
load: function () {
|
||||
var id = new URLSearchParams(location.search).get("s");
|
||||
return id && /^[a-z0-9]{6,32}$/.test(id) ? call("items/" + id) : Promise.resolve(null);
|
||||
},
|
||||
ai: function (prompt, opts) {
|
||||
return post("ai", { prompt: String(prompt || ""), system: opts && opts.system })
|
||||
.then(function (r) { return r && typeof r.text === "string" ? r.text : null; });
|
||||
},
|
||||
user: function () {
|
||||
return accounts ? call("auth/me").then(user) : Promise.resolve(null);
|
||||
},
|
||||
signUp: function (name) {
|
||||
if (!accounts) return Promise.resolve(null);
|
||||
return post("auth/register/start", { name: name }).then(function (o) {
|
||||
if (!o) return null;
|
||||
var pk = o.publicKey;
|
||||
pk.challenge = bytes(pk.challenge);
|
||||
pk.user.id = bytes(pk.user.id);
|
||||
return navigator.credentials.create({ publicKey: pk }).then(function (c) {
|
||||
var r = c.response;
|
||||
return post("auth/register/finish", {
|
||||
challengeId: o.challengeId, id: text(c.rawId), clientDataJSON: text(r.clientDataJSON),
|
||||
authenticatorData: text(r.getAuthenticatorData()), publicKey: text(r.getPublicKey()), alg: r.getPublicKeyAlgorithm()
|
||||
}).then(user);
|
||||
});
|
||||
}).catch(function () { return null; });
|
||||
},
|
||||
signIn: function () {
|
||||
if (!accounts) return Promise.resolve(null);
|
||||
return post("auth/login/start").then(function (o) {
|
||||
if (!o) return null;
|
||||
o.publicKey.challenge = bytes(o.publicKey.challenge);
|
||||
return navigator.credentials.get({ publicKey: o.publicKey }).then(function (c) {
|
||||
var r = c.response;
|
||||
return post("auth/login/finish", {
|
||||
challengeId: o.challengeId, id: text(c.rawId), clientDataJSON: text(r.clientDataJSON),
|
||||
authenticatorData: text(r.authenticatorData), signature: text(r.signature)
|
||||
}).then(user);
|
||||
});
|
||||
}).catch(function () { return null; });
|
||||
},
|
||||
signOut: function () {
|
||||
return accounts ? post("auth/logout").then(function () { return true; }) : Promise.resolve(true);
|
||||
},
|
||||
mine: function () {
|
||||
return accounts ? call("me/items").then(function (r) { return r && r.items ? r.items : null; }) : Promise.resolve(null);
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -1,105 +0,0 @@
|
||||
{
|
||||
"source": "notplanned",
|
||||
"updated_by": "scout",
|
||||
"opportunities": [
|
||||
{
|
||||
"heat": "hot",
|
||||
"repo": "openai/codex",
|
||||
"issue": 40046,
|
||||
"name": "Browser Upload Bridge",
|
||||
"title": "Hidden file-input fallback for agent browsers",
|
||||
"thesis": "Agents still hit browser flows where the native file chooser never fires. Ship a tiny browser/MCP bridge that can target hidden file inputs directly and hand the agent a deterministic upload primitive.",
|
||||
"signal": "Recent Codex request closed as not planned; the underlying Playwright primitive already exists.",
|
||||
"move": "Wrap setInputFiles behind one safe tool + demo it against 5 common upload flows.",
|
||||
"source": "https://github.com/openai/codex/issues/40046"
|
||||
},
|
||||
{
|
||||
"heat": "hot",
|
||||
"repo": "openai/codex",
|
||||
"issue": 46025,
|
||||
"name": "Codex KeepWarm",
|
||||
"title": "Tool calls stall after idle gaps on Windows",
|
||||
"thesis": "If idle transitions are the trigger, a tiny watchdog/keepalive sidecar may remove minutes of dead time without changing Codex itself.",
|
||||
"signal": "September blocker report; every post-idle tool call reportedly incurs ~30s latency and upstream closed it as not planned.",
|
||||
"move": "Reproduce, then test whether a low-cost heartbeat prevents the stall. Kill immediately if it does not.",
|
||||
"source": "https://github.com/openai/codex/issues/46025"
|
||||
},
|
||||
{
|
||||
"heat": "warm",
|
||||
"repo": "openai/codex",
|
||||
"issue": 26966,
|
||||
"name": "Goalkeeper",
|
||||
"title": "Persist goals across codex exec sessions",
|
||||
"thesis": "A wrapper can own the durable goal, inject it into new/resumed runs, and expose goal state programmatically without waiting for first-class CLI support.",
|
||||
"signal": "Two closely related programmatic-goal requests were closed as not planned.",
|
||||
"move": "Build codex-goal with set/show/run; dogfood it on a multi-run task.",
|
||||
"source": "https://github.com/openai/codex/issues/26966"
|
||||
},
|
||||
{
|
||||
"heat": "warm",
|
||||
"repo": "openai/codex",
|
||||
"issue": 14747,
|
||||
"name": "Context Curator",
|
||||
"title": "Scoped compaction for exploratory phases",
|
||||
"thesis": "Treat transcripts as editable context: preserve conclusions, compress dead ends, and emit a clean continuation packet for the next agent/session.",
|
||||
"signal": "Users explicitly want selective rather than whole-history compaction; Claude Code users are asking for priority-aware compaction too.",
|
||||
"move": "Take a real long transcript, select one noisy span, produce a resumable compacted version, measure token savings.",
|
||||
"source": "https://github.com/openai/codex/issues/14747"
|
||||
},
|
||||
{
|
||||
"heat": "warm",
|
||||
"repo": "anthropics/claude-code",
|
||||
"issue": 79948,
|
||||
"name": "Finishline",
|
||||
"title": "The project-management layer coding agents are missing",
|
||||
"thesis": "External supervisor that tracks objective \u2192 evidence \u2192 acceptance, catches unfinished work, and refuses to equate agent completion with done.",
|
||||
"signal": "The request is unusually explicit about sprawl, verification, multi-tier workflow, and agents claiming completion without evidence.",
|
||||
"move": "Ship a repo-local supervisor card that reads task + git state + acceptance checks and produces DONE/WAIT/ANDON.",
|
||||
"source": "https://github.com/anthropics/claude-code/issues/79948"
|
||||
},
|
||||
{
|
||||
"heat": "warm",
|
||||
"repo": "anthropics/claude-code",
|
||||
"issue": 53997,
|
||||
"name": "Preview Sidecar",
|
||||
"title": "Bring back local preview for agent-built apps",
|
||||
"thesis": "A tiny localhost/port discovery sidecar can surface the app the agent is building without requiring the coding product itself to own preview UX.",
|
||||
"signal": "Users describe preview removal as a major regression and specifically want localhost surfaced again.",
|
||||
"move": "Detect active localhost ports and expose one-click preview + screenshot receipt.",
|
||||
"source": "https://github.com/anthropics/claude-code/issues/53997"
|
||||
},
|
||||
{
|
||||
"heat": "warm",
|
||||
"repo": "anthropics/claude-code",
|
||||
"issue": 78197,
|
||||
"name": "Context Priority",
|
||||
"title": "Let users mark low-priority context before compaction",
|
||||
"thesis": "A context sidecar can assign priority to transcript segments and generate a compacted continuation packet that preserves high-value state first.",
|
||||
"signal": "Same underlying pain as scoped compaction, now expressed as explicit message-level priority.",
|
||||
"move": "Prototype priority annotations + deterministic compaction on exported sessions.",
|
||||
"source": "https://github.com/anthropics/claude-code/issues/78197"
|
||||
},
|
||||
{
|
||||
"heat": "warm",
|
||||
"repo": "anthropics/claude-code",
|
||||
"issue": 83489,
|
||||
"name": "Model Presets",
|
||||
"title": "Named model + effort presets",
|
||||
"thesis": "One-click named presets are externally solvable today with a wrapper/menu that atomically selects model and effort and persists the choice.",
|
||||
"signal": "Current product exposes the pieces but not the composed action users want.",
|
||||
"move": "Ship a 3-preset CLI/menu-bar prototype: fast / think / max.",
|
||||
"source": "https://github.com/anthropics/claude-code/issues/83489"
|
||||
},
|
||||
{
|
||||
"heat": "watch",
|
||||
"repo": "vercel-labs/agent-browser",
|
||||
"issue": 1184,
|
||||
"name": "Browser Guardrail",
|
||||
"title": "Per-task domain and form-submission constraints",
|
||||
"thesis": "Put an authority layer in front of browser agents: task-scoped domains, submission policy, and receipts. The browser stays powerful; the delegation gets narrow.",
|
||||
"signal": "The issue frames a real prompt-injection blast-radius problem and upstream closed the feature as not planned.",
|
||||
"move": "Proxy three dangerous actions\u2014navigate, fill, submit\u2014through a task policy and prove denials are deterministic.",
|
||||
"source": "https://github.com/vercel-labs/agent-browser/issues/1184"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
# codex-goal
|
||||
|
||||
A tiny wrapper that remembers one objective per project across separate
|
||||
`codex exec` invocations, and injects it as plain user-context text every
|
||||
time you run or resume. Inspired by
|
||||
[openai/codex#26966: Persist goals across codex exec sessions](https://github.com/openai/codex/issues/26966),
|
||||
closed as not planned. This is **external memory bolted onto the outside of
|
||||
Codex**, not a native goal mode: Codex itself is never changed, and never
|
||||
gets an API to mark the goal "done" or rewrite it.
|
||||
|
||||
## Problem
|
||||
|
||||
`codex exec` is stateless about *why* you're running it. Resume brings back
|
||||
the transcript, but nothing re-states the objective, so a long-running or
|
||||
multi-session task slowly drifts as context gets summarized away. Upstream
|
||||
closed the feature request; this reconstructs it from outside the agent.
|
||||
|
||||
## Install
|
||||
|
||||
Requires Python 3.9+, a POSIX host (macOS/Linux — matches the board's hb-5
|
||||
Mac and Colima container targets), and the `codex` CLI on `PATH` for real
|
||||
use. No pip install, no dependencies:
|
||||
|
||||
```sh
|
||||
cp codex_goal.py ~/bin/codex_goal.py # or run it in place
|
||||
chmod +x codex_goal.py
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
# Save (or replace) the objective for the current directory's project.
|
||||
codex_goal.py set "Migrate the billing module to the new schema, keep tests green"
|
||||
|
||||
# Inspect it any time.
|
||||
codex_goal.py show
|
||||
codex_goal.py show --json --history
|
||||
|
||||
# Start a new codex exec session with the goal injected as user context.
|
||||
codex_goal.py run
|
||||
|
||||
# Resume the most recent session -- goal is re-injected, not just the transcript.
|
||||
codex_goal.py run --last
|
||||
|
||||
# Resume a specific session id, and forward real codex flags/an extra prompt.
|
||||
codex_goal.py run --resume 3fae2b1e-... -- -s workspace-write "focus on the invoices table next"
|
||||
```
|
||||
|
||||
Everything after `run`'s own flags is forwarded to `codex exec` (or
|
||||
`codex exec resume ...`) untouched and in order — sandbox mode, model,
|
||||
`-C`, etc. all still work exactly as they do calling `codex` directly.
|
||||
A trailing bare argument (not starting with `-`) is treated as this turn's
|
||||
extra instruction and appended after the injected goal; everything else is
|
||||
passed straight through.
|
||||
|
||||
`-C DIR` (default: cwd) on any subcommand selects the project. The project
|
||||
key is a hash of its realpath, so `set`/`show`/`run` keep working no matter
|
||||
which subdirectory you're standing in, and two projects never collide.
|
||||
|
||||
## How persistence works
|
||||
|
||||
- State lives under `$CODEX_GOAL_HOME` (default `~/.codex-goal`), **outside**
|
||||
whatever directory `codex exec`'s sandbox would let the agent write to.
|
||||
Codex running in its default `workspace-write` sandbox cannot see or
|
||||
modify its own goal file — `set` is a human-run command, never something
|
||||
the wrapper calls on Codex's behalf.
|
||||
- Goal records are versioned: each `set` pushes the previous value into a
|
||||
capped history (50 entries) instead of overwriting it silently.
|
||||
- Writes are atomic (`tempfile` + `os.replace`) and guarded by an exclusive
|
||||
file lock (`fcntl.flock`) around the whole read-modify-write, so a crash
|
||||
or a concurrent `set` can't leave a half-written or torn JSON file.
|
||||
- `run` never modifies the goal. It only appends one line to a separate
|
||||
run-log (`$CODEX_GOAL_HOME/runs/<key>.jsonl`) *after* the child process
|
||||
exits, recording the mode, a goal preview, the exact argv, and the real
|
||||
exit code. A run that crashes mid-flight (killed, SIGKILL, power loss)
|
||||
leaves both the goal and the prior run-log lines untouched — there is no
|
||||
"mark this goal complete" affordance for Codex or the wrapper to call, so
|
||||
nothing can silently clear it.
|
||||
- Codex is spawned via `subprocess.Popen([...])` with an argv list, never a
|
||||
shell string — a goal containing `; rm -rf / #` is passed as one opaque
|
||||
argv element, not executed. The child gets its own process group, and
|
||||
`SIGINT`/`SIGTERM` sent to the wrapper are forwarded to it, so Ctrl-C
|
||||
cancels a run the same way it would cancel `codex` directly. The
|
||||
wrapper's own exit code is the child's exit code.
|
||||
|
||||
## Limits and trust
|
||||
|
||||
- One objective per project at a time. It is not a task list, not a
|
||||
scheduler, and it does not talk to the Codex API directly — it only
|
||||
shells out to the `codex` binary you already have configured and
|
||||
authenticated.
|
||||
- The injected text is plain, visible user context ("[codex-goal] Persisted
|
||||
objective...") — it carries no special authority inside Codex's own
|
||||
permission model. A model can still choose to ignore it, same as any
|
||||
other instruction.
|
||||
- No enforcement that the agent is *actually* working the stated goal; this
|
||||
only guarantees the goal text is faithfully re-presented every run. It
|
||||
does not inspect diffs, run checks, or grade progress — see
|
||||
[finishline-supervisor](../finishline-supervisor/) for that kind of
|
||||
evidence wrapper, which composes with this one but is a separate tool.
|
||||
- `$CODEX_GOAL_HOME` is trusted local storage with your own file
|
||||
permissions; this tool adds no encryption or extra access control. Don't
|
||||
put secrets in goal text — it is written to disk in plain JSON.
|
||||
- Locking is advisory (`flock`) and local-filesystem only; it does not work
|
||||
over NFS/SMB and does not coordinate across machines.
|
||||
- `run` passes through whatever `codex exec` flags you give it, including
|
||||
`--dangerously-bypass-approvals-and-sandbox` if you ask for it — this
|
||||
wrapper does not add or remove any safety behavior from Codex itself.
|
||||
|
||||
## Develop and verify
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p 'test_*.py' -v
|
||||
python3 demo.py > demo.txt # recorded, runnable, no network/auth needed
|
||||
```
|
||||
|
||||
Tests and the demo use `tests/fake_codex.py`, a deterministic stand-in for
|
||||
the real `codex` binary (records the exact argv it received, exits with a
|
||||
configurable code, optionally sleeps so cancellation can be tested) — no
|
||||
model calls, no API key, nothing billed, per the board's preference for
|
||||
deterministic, cheap, inspectable checks over live model calls. [Recorded
|
||||
demo output](demo.txt) covers: `set`/`show` round trip, run 1 (fresh
|
||||
session), run 2 (`--last` resume, same goal), and a `kill -9` mid-run
|
||||
followed by a `show` proving the goal survived byte-for-byte.
|
||||
|
||||
Verified: all 16 unit tests plus the demo on the local macOS (hb-5) host,
|
||||
and inside the project's Colima Linux container (same Python 3, same
|
||||
`tests/fake_codex.py`, no code changes needed — the tool has no
|
||||
platform-specific dependency beyond POSIX `fcntl`/process groups). Real
|
||||
`codex` binary integration (the actual CLI shown in Usage above) was
|
||||
exercised manually against the installed `codex` CLI's `--help` output to
|
||||
confirm the `exec`/`exec resume --last`/flag-forwarding argv shape this
|
||||
wrapper builds matches the real CLI; it was not run against a live Codex
|
||||
backend (that needs your own account/auth and would spend real requests).
|
||||
@@ -1,302 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""codex-goal: persist a per-project objective across `codex exec` sessions.
|
||||
|
||||
External memory, not native Codex state: it stores the goal outside the
|
||||
directory Codex is allowed to write to, and injects it as plain user-context
|
||||
text on every `set`/`show`/`run` the human runs. Codex itself never sees a
|
||||
"mark complete" affordance -- only `set` can change the stored goal, and
|
||||
`set` is never invoked by codex-goal's own `run` command.
|
||||
|
||||
Usage:
|
||||
codex_goal.py set [-C DIR] GOAL_TEXT...
|
||||
codex_goal.py show [-C DIR] [--json] [--history]
|
||||
codex_goal.py run [-C DIR] [--codex-bin PATH] [--resume ID | --last]
|
||||
[--dry-run] [-- CODEX_ARGS... [PROMPT]]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError: # pragma: no cover - POSIX only, matches board brief hosts
|
||||
fcntl = None
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
MAX_GOAL_CHARS = 4000
|
||||
MAX_HISTORY = 50
|
||||
ENV_HOME = "CODEX_GOAL_HOME"
|
||||
|
||||
|
||||
def state_home() -> str:
|
||||
override = os.environ.get(ENV_HOME)
|
||||
if override:
|
||||
return override
|
||||
return os.path.join(os.path.expanduser("~"), ".codex-goal")
|
||||
|
||||
|
||||
def project_key(project_dir: str) -> str:
|
||||
real = os.path.realpath(project_dir)
|
||||
digest = hashlib.sha256(real.encode("utf-8")).hexdigest()[:16]
|
||||
base = os.path.basename(real.rstrip("/")) or "root"
|
||||
safe_base = "".join(c if c.isalnum() or c in "-_." else "-" for c in base)
|
||||
return f"{safe_base}-{digest}"
|
||||
|
||||
|
||||
def goal_path(home: str, key: str) -> str:
|
||||
return os.path.join(home, "goals", f"{key}.json")
|
||||
|
||||
|
||||
def runlog_path(home: str, key: str) -> str:
|
||||
return os.path.join(home, "runs", f"{key}.jsonl")
|
||||
|
||||
|
||||
def lock_path(home: str, key: str) -> str:
|
||||
return os.path.join(home, "locks", f"{key}.lock")
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def locked(home: str, key: str):
|
||||
"""Exclusive file lock guarding read-modify-write of one project's state."""
|
||||
path = lock_path(home, key)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
fh = open(path, "a+")
|
||||
try:
|
||||
if fcntl is not None:
|
||||
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
if fcntl is not None:
|
||||
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
||||
fh.close()
|
||||
|
||||
|
||||
def atomic_write_json(path: str, payload: dict) -> None:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(prefix=".tmp-", dir=os.path.dirname(path))
|
||||
try:
|
||||
with os.fdopen(fd, "w") as fh:
|
||||
json.dump(payload, fh, indent=2, sort_keys=True)
|
||||
fh.write("\n")
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp, path) # atomic on POSIX: a reader never sees a partial file
|
||||
except Exception:
|
||||
with contextlib.suppress(OSError):
|
||||
os.remove(tmp)
|
||||
raise
|
||||
|
||||
|
||||
def append_jsonl(path: str, record: dict) -> None:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
line = json.dumps(record, sort_keys=True) + "\n"
|
||||
with open(path, "a") as fh:
|
||||
fh.write(line)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
|
||||
def load_state(path: str) -> dict | None:
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
with open(path) as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def cmd_set(args: argparse.Namespace) -> int:
|
||||
text = " ".join(args.goal).strip()
|
||||
if not text:
|
||||
print("codex-goal: refusing an empty goal", file=sys.stderr)
|
||||
return 2
|
||||
if len(text) > MAX_GOAL_CHARS:
|
||||
print(f"codex-goal: goal exceeds {MAX_GOAL_CHARS} characters", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
home = state_home()
|
||||
key = project_key(args.project)
|
||||
path = goal_path(home, key)
|
||||
|
||||
with locked(home, key):
|
||||
state = load_state(path)
|
||||
ts = now_iso()
|
||||
if state is None:
|
||||
state = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"project": {"path": os.path.realpath(args.project), "key": key},
|
||||
"current": None,
|
||||
"history": [],
|
||||
}
|
||||
if state["current"] is not None:
|
||||
state["history"].append(state["current"])
|
||||
state["history"] = state["history"][-MAX_HISTORY:]
|
||||
state["current"] = {
|
||||
"goal_id": f"g-{uuid.uuid4().hex[:12]}",
|
||||
"text": text,
|
||||
"created_at": ts,
|
||||
"updated_at": ts,
|
||||
}
|
||||
atomic_write_json(path, state)
|
||||
|
||||
print(f"codex-goal: saved goal {state['current']['goal_id']} for {key}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_show(args: argparse.Namespace) -> int:
|
||||
home = state_home()
|
||||
key = project_key(args.project)
|
||||
state = load_state(goal_path(home, key))
|
||||
|
||||
if state is None or state.get("current") is None:
|
||||
if args.json:
|
||||
print(json.dumps({"project_key": key, "current": None}))
|
||||
else:
|
||||
print(f"codex-goal: no goal set for {key}. Run: codex_goal.py set <goal>")
|
||||
return 1
|
||||
|
||||
if args.json:
|
||||
payload = {"project_key": key, "current": state["current"]}
|
||||
if args.history:
|
||||
payload["history"] = state["history"]
|
||||
print(json.dumps(payload, indent=2, sort_keys=True))
|
||||
else:
|
||||
cur = state["current"]
|
||||
print(f"project {key}")
|
||||
print(f"goal_id {cur['goal_id']}")
|
||||
print(f"updated {cur['updated_at']}")
|
||||
print(f"text {cur['text']}")
|
||||
if args.history and state["history"]:
|
||||
print("\nhistory (oldest first):")
|
||||
for h in state["history"]:
|
||||
print(f" - [{h['updated_at']}] {h['goal_id']}: {h['text']}")
|
||||
return 0
|
||||
|
||||
|
||||
INJECT_HEADER = "[codex-goal] Persisted objective for this project (external memory, injected by a wrapper -- not native Codex state):"
|
||||
INJECT_FOOTER = "[/codex-goal] Continue working toward the objective above unless the user says otherwise."
|
||||
|
||||
|
||||
def build_prompt(goal_text: str, extra_prompt: str | None) -> str:
|
||||
block = f"{INJECT_HEADER}\n{goal_text}\n{INJECT_FOOTER}"
|
||||
if extra_prompt:
|
||||
return f"{block}\n\n{extra_prompt}"
|
||||
return block
|
||||
|
||||
|
||||
def cmd_run(args: argparse.Namespace) -> int:
|
||||
home = state_home()
|
||||
key = project_key(args.project)
|
||||
state = load_state(goal_path(home, key))
|
||||
if state is None or state.get("current") is None:
|
||||
print(f"codex-goal: no goal set for {key}. Run: codex_goal.py set <goal>", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
goal = state["current"]
|
||||
passthrough = list(args.codex_args)
|
||||
extra_prompt = None
|
||||
if passthrough and not passthrough[-1].startswith("-"):
|
||||
# Last bare token (if any) is the user's extra instruction for this turn;
|
||||
# everything else is forwarded to `codex exec` untouched.
|
||||
extra_prompt = passthrough.pop()
|
||||
|
||||
prompt = build_prompt(goal["text"], extra_prompt)
|
||||
|
||||
argv = [args.codex_bin, "exec"]
|
||||
mode = "new"
|
||||
session_arg = None
|
||||
if args.resume or args.last:
|
||||
argv.append("resume")
|
||||
mode = "resume"
|
||||
if args.last:
|
||||
argv.append("--last")
|
||||
session_arg = "last"
|
||||
else:
|
||||
argv.append(args.resume)
|
||||
session_arg = args.resume
|
||||
argv.extend(passthrough)
|
||||
argv.append(prompt)
|
||||
|
||||
if args.dry_run:
|
||||
print(json.dumps({"argv": argv, "mode": mode}, indent=2))
|
||||
return 0
|
||||
|
||||
record = {
|
||||
"ts": now_iso(),
|
||||
"mode": mode,
|
||||
"session_arg": session_arg,
|
||||
"goal_id": goal["goal_id"],
|
||||
"goal_preview": goal["text"][:120],
|
||||
"argv": argv,
|
||||
}
|
||||
|
||||
try:
|
||||
proc = subprocess.Popen(argv, start_new_session=True)
|
||||
except FileNotFoundError:
|
||||
print(f"codex-goal: codex binary not found: {args.codex_bin}", file=sys.stderr)
|
||||
record["exit_code"] = 127
|
||||
append_jsonl(runlog_path(home, key), record)
|
||||
return 127
|
||||
|
||||
def forward_signal(signum, _frame):
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.killpg(proc.pid, signum)
|
||||
|
||||
old_sigint = signal.signal(signal.SIGINT, forward_signal)
|
||||
old_sigterm = signal.signal(signal.SIGTERM, forward_signal)
|
||||
try:
|
||||
exit_code = proc.wait()
|
||||
finally:
|
||||
signal.signal(signal.SIGINT, old_sigint)
|
||||
signal.signal(signal.SIGTERM, old_sigterm)
|
||||
|
||||
record["exit_code"] = exit_code
|
||||
append_jsonl(runlog_path(home, key), record)
|
||||
return exit_code
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(prog="codex_goal.py", description=__doc__)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
common = argparse.ArgumentParser(add_help=False)
|
||||
common.add_argument("-C", "--project", default=os.getcwd(), help="project directory (default: cwd)")
|
||||
|
||||
p_set = sub.add_parser("set", parents=[common], help="save/replace the goal for this project")
|
||||
p_set.add_argument("goal", nargs="+", help="goal text")
|
||||
p_set.set_defaults(func=cmd_set)
|
||||
|
||||
p_show = sub.add_parser("show", parents=[common], help="print the current goal")
|
||||
p_show.add_argument("--json", action="store_true")
|
||||
p_show.add_argument("--history", action="store_true")
|
||||
p_show.set_defaults(func=cmd_show)
|
||||
|
||||
p_run = sub.add_parser("run", parents=[common], help="spawn `codex exec` with the goal injected")
|
||||
p_run.add_argument("--codex-bin", default=os.environ.get("CODEX_GOAL_BIN", "codex"))
|
||||
resume_group = p_run.add_mutually_exclusive_group()
|
||||
resume_group.add_argument("--resume", metavar="SESSION_ID", help="resume this session id")
|
||||
resume_group.add_argument("--last", action="store_true", help="resume the most recent session")
|
||||
p_run.add_argument("--dry-run", action="store_true", help="print the argv that would run, do not spawn")
|
||||
p_run.add_argument("codex_args", nargs=argparse.REMAINDER, metavar="-- CODEX_ARGS...")
|
||||
p_run.set_defaults(func=cmd_run)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
if args.command == "run" and args.codex_args and args.codex_args[0] == "--":
|
||||
args.codex_args = args.codex_args[1:]
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -1,38 +0,0 @@
|
||||
# 1. set + show
|
||||
$ python3 codex_goal.py set -C '$PROJECT' 'Ship codex-goal'"'"'s two-run persistence demo'
|
||||
codex-goal: saved goal g-a2c8350ac309 for myapp-58cda05ecfff4ec2
|
||||
[exit 0] goal saved
|
||||
|
||||
$ python3 codex_goal.py show -C '$PROJECT'
|
||||
project myapp-58cda05ecfff4ec2
|
||||
goal_id g-a2c8350ac309
|
||||
updated 2026-10-10T20:16:17Z
|
||||
text Ship codex-goal's two-run persistence demo
|
||||
[exit 0] goal shown
|
||||
|
||||
# 2. run 1 -- fresh session (fake codex, exit 0)
|
||||
$ python3 codex_goal.py run -C '$PROJECT' --codex-bin tests/fake_codex.py
|
||||
[exit 0] run 1 done
|
||||
|
||||
# 3. run 2 -- resume --last, in a *new* process, goal still there
|
||||
$ python3 codex_goal.py run -C '$PROJECT' --last --codex-bin tests/fake_codex.py
|
||||
[exit 0] run 2 done
|
||||
|
||||
# 4. crash mid-run: kill -9 the wrapper while codex is 'working'
|
||||
$ (background run) ; kill -9 35478
|
||||
[killed] simulated crash
|
||||
|
||||
# 5. show again -- goal survived the crash, byte for byte
|
||||
$ python3 codex_goal.py show -C '$PROJECT' --json
|
||||
{
|
||||
"current": {
|
||||
"created_at": "2026-10-10T20:16:17Z",
|
||||
"goal_id": "g-a2c8350ac309",
|
||||
"text": "Ship codex-goal's two-run persistence demo",
|
||||
"updated_at": "2026-10-10T20:16:17Z"
|
||||
},
|
||||
"project_key": "myapp-58cda05ecfff4ec2"
|
||||
}
|
||||
[exit 0] goal after crash
|
||||
|
||||
ASSERTION PASSED: goal text identical before and after the crash.
|
||||
@@ -1,95 +0,0 @@
|
||||
Copyright (c) 2017, keshikan (http://www.keshikan.net),
|
||||
with Reserved Font Name "DSEG".
|
||||
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -1,345 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="description" content="codex-goal: a small wrapper that remembers one objective per project across separate codex exec runs, and injects it back in every time. Free CLI, no account.">
|
||||
<title>codex-goal · notplanned</title>
|
||||
<style>
|
||||
@font-face{
|
||||
font-family:"DSEG7";
|
||||
src:url("fonts/DSEG7Classic-Bold.woff2") format("woff2");
|
||||
font-weight:700;font-style:normal;font-display:swap;
|
||||
}
|
||||
:root{
|
||||
--court:#0a0e14; --court-2:#0f151e;
|
||||
--panel:#141b26; --panel-line:#232e3f;
|
||||
--ink:#eef3fa; --mute:#8a97ab;
|
||||
--amber:#ffb400; --amber-dim:#5a4418;
|
||||
--green:#28e07a; --green-dim:#123321;
|
||||
--red:#ff4d5e; --red-dim:#3a1218;
|
||||
--blue:#39c3ff; --blue-dim:#0f2c3a;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;background:var(--court);color:var(--ink);overflow-x:hidden}
|
||||
body{
|
||||
font-family:ui-sans-serif,system-ui,"Helvetica Neue",Arial,sans-serif;
|
||||
background-image:
|
||||
radial-gradient(900px 420px at 15% -8%, rgba(255,180,0,.08), transparent),
|
||||
radial-gradient(900px 420px at 110% 10%, rgba(57,195,255,.08), transparent),
|
||||
repeating-linear-gradient(0deg, rgba(255,255,255,.015) 0 1px, transparent 1px 34px),
|
||||
var(--court);
|
||||
}
|
||||
a{color:inherit;text-decoration-color:var(--mute)}
|
||||
.wrap{width:min(1040px,calc(100% - 28px));margin:0 auto}
|
||||
.tag{font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}
|
||||
.led{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--green);box-shadow:0 0 10px var(--green);animation:blink 1.6s step-end infinite}
|
||||
@keyframes blink{0%,49%{opacity:1}50%,100%{opacity:.25}}
|
||||
@media (prefers-reduced-motion:reduce){.led{animation:none}}
|
||||
|
||||
header.bar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px 14px;padding:18px 0;border-bottom:1px solid var(--panel-line)}
|
||||
header.bar .brand{font-weight:900;letter-spacing:-.02em;font-size:15px;text-transform:uppercase}
|
||||
header.bar .brand span{color:var(--amber)}
|
||||
header.bar .ticker{display:flex;align-items:center;gap:8px;max-width:100%;overflow-wrap:anywhere}
|
||||
|
||||
.marquee{padding:52px 0 18px}
|
||||
.kicker{display:inline-flex;align-items:center;gap:8px;border:1px solid var(--panel-line);background:var(--panel);border-radius:999px;padding:7px 14px;font-size:11.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--mute);margin-bottom:22px;max-width:100%;overflow-wrap:anywhere}
|
||||
h1.title{
|
||||
font-size:clamp(38px,6.6vw,74px);line-height:.98;letter-spacing:-.03em;margin:0 0 20px;
|
||||
text-transform:uppercase;font-weight:900;
|
||||
}
|
||||
h1.title .hl{color:var(--court);background:var(--amber);padding:0 .12em;box-decoration-break:clone;-webkit-box-decoration-break:clone}
|
||||
h1.title .hl2{color:var(--court);background:var(--green);padding:0 .12em;box-decoration-break:clone;-webkit-box-decoration-break:clone}
|
||||
p.lead{max-width:640px;font-size:clamp(16px,2vw,19px);line-height:1.5;color:#c7d0dd;margin:0 0 30px}
|
||||
p.lead code{background:var(--panel);border:1px solid var(--panel-line);border-radius:5px;padding:1px 6px;font-size:.92em}
|
||||
|
||||
.scorerow{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin:0 0 34px}
|
||||
.scorecell{background:#000;border:2px solid var(--panel-line);border-radius:10px;padding:14px 10px;text-align:center;position:relative;overflow:hidden}
|
||||
.scorecell::before{content:"";position:absolute;inset:0;background:repeating-linear-gradient(180deg,rgba(255,255,255,.03) 0 2px,transparent 2px 4px);pointer-events:none}
|
||||
.scorecell .digits{font-family:"DSEG7",ui-monospace,monospace;font-weight:700;font-size:clamp(26px,4.2vw,40px);letter-spacing:.04em;line-height:1}
|
||||
.scorecell.amber .digits{color:var(--amber);text-shadow:0 0 14px rgba(255,180,0,.65)}
|
||||
.scorecell.green .digits{color:var(--green);text-shadow:0 0 14px rgba(40,224,122,.65)}
|
||||
.scorecell.blue .digits{color:var(--blue);text-shadow:0 0 14px rgba(57,195,255,.65)}
|
||||
.scorecell .caption{margin-top:8px;font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute)}
|
||||
|
||||
.panel{background:var(--panel);border:1px solid var(--panel-line);border-radius:16px}
|
||||
.join-panel{padding:26px;margin-bottom:46px;border-top:4px solid var(--amber)}
|
||||
.join-panel .label{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--amber);font-weight:800;margin-bottom:12px}
|
||||
form.join{display:grid;grid-template-columns:1.3fr 1fr auto;gap:10px;align-items:start}
|
||||
.field{display:flex;flex-direction:column;gap:5px;min-width:0}
|
||||
.field label{font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--mute)}
|
||||
input[type=email],input[type=text],select{
|
||||
width:100%;font-family:inherit;font-size:14px;padding:11px 12px;border-radius:9px;
|
||||
border:1px solid var(--panel-line);background:var(--court-2);color:var(--ink);
|
||||
}
|
||||
input::placeholder{color:#55606f}
|
||||
.note-row{grid-column:1/-1}
|
||||
.hp{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}
|
||||
button.cta{
|
||||
align-self:end;padding:12px 20px;border-radius:9px;border:0;background:var(--amber);color:#201400;
|
||||
font-weight:800;letter-spacing:.03em;font-size:14px;cursor:pointer;white-space:nowrap;
|
||||
}
|
||||
button.cta:hover{background:#ffc83d}
|
||||
.fineprint{font-size:11.5px;color:var(--mute);margin:12px 0 0;grid-column:1/-1}
|
||||
.thanks{display:none;font-size:15px;font-weight:700;color:var(--green)}
|
||||
.thanks small{display:block;margin-top:6px;font-weight:400;color:var(--mute);font-size:12px}
|
||||
.join-panel.joined form.join,.join-panel.joined .label{display:none}
|
||||
.join-panel.joined .thanks{display:block}
|
||||
|
||||
section{margin:54px 0}
|
||||
h2.h{font-size:clamp(22px,3vw,30px);text-transform:uppercase;letter-spacing:-.01em;font-weight:900;margin:0 0 6px}
|
||||
p.sub{color:var(--mute);font-size:14.5px;margin:0 0 22px;max-width:620px}
|
||||
|
||||
.matchup{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||
.team{border-radius:14px;padding:20px;border:1px solid var(--panel-line)}
|
||||
.team.lose{background:var(--red-dim);border-color:#5c1f29}
|
||||
.team.win{background:var(--green-dim);border-color:#1d5a3b}
|
||||
.team .score{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:10px}
|
||||
.team .name{font-weight:900;text-transform:uppercase;font-size:13.5px;letter-spacing:.05em}
|
||||
.team.lose .name{color:#ff8a94}
|
||||
.team.win .name{color:#7cf0ad}
|
||||
.team .result{font-weight:900;font-size:13px;letter-spacing:.08em;padding:3px 9px;border-radius:5px}
|
||||
.team.lose .result{color:var(--red);background:rgba(255,77,94,.15);border:1px solid #5c1f29}
|
||||
.team.win .result{color:var(--green);background:rgba(40,224,122,.15);border:1px solid #1d5a3b}
|
||||
.team pre{margin:0;background:#000;border:1px solid rgba(255,255,255,.08);border-radius:9px;padding:12px;font-size:12.3px;line-height:1.55;overflow:auto;white-space:pre-wrap;word-break:break-word;color:#d9e2ee}
|
||||
.team p.cap{font-size:11.5px;color:var(--mute);margin:10px 0 0}
|
||||
|
||||
.replay{position:relative;border-radius:16px;overflow:hidden;border:1px solid var(--panel-line);background:#05070a}
|
||||
.replay .crt-head{display:flex;align-items:center;gap:8px;padding:10px 14px;background:var(--court-2);border-bottom:1px solid var(--panel-line);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--mute)}
|
||||
.replay .crt-head .dot{width:9px;height:9px;border-radius:50%}
|
||||
.replay .crt-head .dot.r{background:var(--red)}.replay .crt-head .dot.y{background:var(--amber)}.replay .crt-head .dot.g{background:var(--green)}
|
||||
.replay pre{margin:0;padding:18px;font-size:12.3px;line-height:1.6;overflow:auto;white-space:pre-wrap;word-break:break-word;color:#bfe8c9;max-height:420px}
|
||||
.replay pre .fx{color:var(--amber)}
|
||||
.replay::after{content:"";position:absolute;inset:0;background:repeating-linear-gradient(180deg,rgba(255,255,255,.025) 0 1px,transparent 1px 3px);pointer-events:none}
|
||||
|
||||
ul.rules{margin:0;padding-left:0;list-style:none;display:grid;gap:10px}
|
||||
ul.rules li{display:flex;gap:10px;padding:12px 14px;background:var(--panel);border:1px solid var(--panel-line);border-radius:10px;font-size:13.8px;line-height:1.5;color:#d4dce7}
|
||||
ul.rules li .flag{flex:0 0 auto;font-weight:900;color:var(--red);font-size:11px;letter-spacing:.06em;text-transform:uppercase;border:1px solid #5c1f29;background:var(--red-dim);padding:2px 7px;border-radius:5px;height:fit-content}
|
||||
ul.rules li .desc{flex:1 1 auto;min-width:0}
|
||||
|
||||
ul.howitworks{margin:0;padding-left:0;list-style:none;display:grid;gap:9px}
|
||||
ul.howitworks li{display:flex;gap:10px;align-items:flex-start;font-size:14px;line-height:1.55;color:#d4dce7}
|
||||
ul.howitworks li .pt{flex:0 0 auto;font-family:"DSEG7",ui-monospace,monospace;color:var(--green);font-size:15px;min-width:30px}
|
||||
ul.howitworks li .desc{flex:1 1 auto;min-width:0}
|
||||
|
||||
.passes{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
|
||||
.pass{border-radius:14px;padding:22px;border:1px solid var(--panel-line);background:var(--panel);display:flex;flex-direction:column}
|
||||
.pass .tier{font-weight:900;text-transform:uppercase;font-size:14px;letter-spacing:.04em;margin-bottom:6px}
|
||||
.pass.free{border-top:4px solid var(--blue)}
|
||||
.pass.early{border-top:4px solid var(--amber)}
|
||||
.pass.team{border-top:4px solid var(--green)}
|
||||
.pass .price{font-size:26px;margin-bottom:4px;font-weight:800}
|
||||
.pass .price .cur{font-family:inherit}
|
||||
.pass .price .num{font-family:"DSEG7",ui-monospace,monospace;font-weight:700}
|
||||
.pass .price .per{font-family:inherit;font-size:14px;font-weight:600;color:var(--mute);margin-left:2px}
|
||||
.pass.free .price .num{color:var(--blue)}
|
||||
.pass.early .price .num{color:var(--amber)}
|
||||
.pass.team .price .num{color:var(--green)}
|
||||
.pass .for{color:var(--mute);font-size:12.5px;margin-bottom:12px}
|
||||
.pass ul{margin:0 0 0;padding-left:16px;font-size:13.3px;line-height:1.65;color:#d4dce7}
|
||||
.pass .founding{margin-top:12px;font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--mute)}
|
||||
|
||||
footer.bar{border-top:1px solid var(--panel-line);padding:26px 0 44px;margin-top:60px}
|
||||
footer.bar .links{display:flex;gap:18px;flex-wrap:wrap;font-size:13px;color:var(--mute);margin-bottom:14px}
|
||||
footer.bar .score-tape{display:flex;gap:2px;height:7px;margin-bottom:10px}
|
||||
footer.bar .score-tape span{flex:1;background:var(--panel-line)}
|
||||
footer.bar .score-tape span:nth-child(3n){background:var(--amber);opacity:.5}
|
||||
footer.bar .fine{font-size:11px;color:#5c6779;line-height:1.6}
|
||||
|
||||
@media (max-width:760px){
|
||||
form.join{grid-template-columns:1fr}
|
||||
button.cta{width:100%}
|
||||
.scorerow{grid-template-columns:1fr 1fr}
|
||||
.matchup{grid-template-columns:1fr}
|
||||
.passes{grid-template-columns:1fr}
|
||||
}
|
||||
@media (max-width:480px){
|
||||
.marquee{padding:34px 0 10px}
|
||||
.scorerow{grid-template-columns:1fr}
|
||||
h1.title{font-size:clamp(30px,9.5vw,74px)}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header class="bar">
|
||||
<div class="brand">NOTPLANNED <span>ARCADE</span></div>
|
||||
<div class="ticker tag"><span class="led" aria-hidden="true"></span> VENTURE 004 · LIVE</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="marquee">
|
||||
<div class="kicker">upstream call: openai/codex#26966 · closed · not planned</div>
|
||||
<h1 class="title">don't let the agent forget <span class="hl">the goal.</span></h1>
|
||||
<p class="lead"><code>codex exec</code> resumes a transcript but never restates <em>why</em>. codex‑goal is a small wrapper that stores one objective per project outside Codex's own workspace, and hands it back as plain context on every new or resumed run — <span class="hl2">built anyway</span> after upstream closed the request.</p>
|
||||
|
||||
<div class="scorerow" aria-label="Recorded demo tally">
|
||||
<div class="scorecell amber"><div class="digits">02</div><div class="caption">codex exec runs</div></div>
|
||||
<div class="scorecell green"><div class="digits">01</div><div class="caption">goal, both runs</div></div>
|
||||
<div class="scorecell blue"><div class="digits">100</div><div class="caption">% intact after kill -9</div></div>
|
||||
</div>
|
||||
|
||||
<div class="panel join-panel" id="join">
|
||||
<div class="label">★ join the roster — be first when hosted tiers ship</div>
|
||||
<form class="join" action="/api/signup" method="post" enctype="application/x-www-form-urlencoded">
|
||||
<input type="hidden" name="venture" value="codex-goal">
|
||||
<input class="hp" type="text" name="website" value="" tabindex="-1" autocomplete="off" aria-hidden="true">
|
||||
<div class="field">
|
||||
<label for="email">email</label>
|
||||
<input id="email" type="email" name="email" required placeholder="you@yourteam.dev">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="plan">plan</label>
|
||||
<select id="plan" name="plan" required>
|
||||
<option value="free">Free — just the CLI</option>
|
||||
<option value="early_access">Early Access — $6/mo, founding price</option>
|
||||
<option value="team">Team — $24/mo, founding price</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="cta" type="submit">Draft me →</button>
|
||||
<div class="note-row field">
|
||||
<label for="note">note (optional)</label>
|
||||
<input id="note" type="text" name="note" placeholder="what are you running codex on?">
|
||||
</div>
|
||||
</form>
|
||||
<div class="thanks">✓ ON THE ROSTER — thanks, you're in.<small>We'll only email you about codex-goal. Never shared.</small></div>
|
||||
<p class="fineprint">One line on your email: used only to reach you about codex-goal, never sold, never shared. The CLI below stays free no matter what you pick.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="h">the matchup</h2>
|
||||
<p class="sub">Same two commands, two sessions apart. One remembers the assignment; one doesn't.</p>
|
||||
<div class="matchup">
|
||||
<div class="team lose">
|
||||
<div class="score"><span class="name">Stock codex exec</span><span class="result">LOST</span></div>
|
||||
<pre>$ codex exec "migrate billing to new schema"
|
||||
… session ends, summarized …
|
||||
|
||||
$ codex exec resume --last "continue"
|
||||
> continuing. what's the task again?
|
||||
(transcript survived. the <em>point</em> of it didn't.)</pre>
|
||||
<p class="cap">Resume brings back messages, not the objective that framed them.</p>
|
||||
</div>
|
||||
<div class="team win">
|
||||
<div class="score"><span class="name">codex‑goal run</span><span class="result">WON</span></div>
|
||||
<pre>$ codex_goal.py set "migrate billing to new schema"
|
||||
$ codex_goal.py run
|
||||
> [codex-goal] persisted objective: migrate billing…
|
||||
|
||||
… session ends …
|
||||
|
||||
$ codex_goal.py run --last
|
||||
> [codex-goal] persisted objective: migrate billing…
|
||||
(re-injected before Codex sees anything else.)</pre>
|
||||
<p class="cap">Every new or resumed run gets the same goal text, re-stated, every time.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="h">instant replay</h2>
|
||||
<p class="sub">Real, runnable output from <code>demo.py</code> — a disposable project dir, a disposable <code>$CODEX_GOAL_HOME</code>, and a deterministic stand-in for the real <code>codex</code> binary (no network, no account, nothing billed). Two separate process runs see the same goal; a <code>kill -9</code> mid-run doesn't touch it.</p>
|
||||
<div class="replay">
|
||||
<div class="crt-head"><span class="dot r"></span><span class="dot y"></span><span class="dot g"></span> demo.py — full output in demo.txt</div>
|
||||
<pre><code>$ python3 codex_goal.py set -C $PROJECT 'Ship codex-goal's two-run persistence demo'
|
||||
codex-goal: saved goal g-a2c8350ac309 for myapp-58cda05ecfff4ec2
|
||||
[exit 0] goal saved
|
||||
|
||||
$ python3 codex_goal.py run -C $PROJECT --codex-bin tests/fake_codex.py
|
||||
[exit 0] run 1 done <span class="fx">← fresh session, goal injected</span>
|
||||
|
||||
$ python3 codex_goal.py run -C $PROJECT --last --codex-bin tests/fake_codex.py
|
||||
[exit 0] run 2 done <span class="fx">← resumed, SAME goal injected again</span>
|
||||
|
||||
$ (background run) ; kill -9 35478
|
||||
[killed] simulated crash <span class="fx">← mid-run, hard kill</span>
|
||||
|
||||
$ python3 codex_goal.py show -C $PROJECT --json
|
||||
{
|
||||
"current": {
|
||||
"text": "Ship codex-goal's two-run persistence demo",
|
||||
"goal_id": "g-a2c8350ac309"
|
||||
}
|
||||
}
|
||||
ASSERTION PASSED: goal text identical before and after the crash.</code></pre>
|
||||
</div>
|
||||
<p class="fineprint" style="margin-top:12px">Full transcript: <a href="demo.txt">demo.txt</a> · run it yourself: <a href="README.md">README</a></p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="h">how it scores</h2>
|
||||
<ul class="howitworks">
|
||||
<li><span class="pt">01</span> <span class="desc">Goal state lives under <code>~/.codex-goal</code> — outside the directory Codex's own sandbox lets it write to. <code>set</code> is a human command; the wrapper never calls it on Codex's behalf.</span></li>
|
||||
<li><span class="pt">02</span> <span class="desc">Every <code>set</code> is versioned: the previous goal moves to a capped history instead of being silently overwritten.</span></li>
|
||||
<li><span class="pt">03</span> <span class="desc">Writes are atomic (temp file + rename) under an exclusive file lock — a crash mid-write can't leave torn JSON.</span></li>
|
||||
<li><span class="pt">04</span> <span class="desc"><code>run</code> never edits the goal. It only appends one line to a separate run-log, after the child exits, with the real exit code.</span></li>
|
||||
<li><span class="pt">05</span> <span class="desc">Codex is spawned via an argv list, never a shell string — a goal containing <code>; rm -rf / #</code> is one opaque argument, not a command.</span></li>
|
||||
<li><span class="pt">06</span> <span class="desc">Ctrl‑C / SIGTERM sent to the wrapper is forwarded to the Codex child, so cancellation behaves exactly like calling <code>codex</code> directly. The wrapper's exit code is the child's.</span></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="h">game passes</h2>
|
||||
<p class="sub">Free tier works offline, forever. Paid tiers are founding-price early access — there is no checkout yet.</p>
|
||||
<div class="passes">
|
||||
<div class="pass free">
|
||||
<div class="tier">Free</div>
|
||||
<div class="price"><span class="cur">$</span><span class="num">0</span></div>
|
||||
<div class="for">anyone with their own Codex auth</div>
|
||||
<ul><li>Full CLI, source, tests, demo</li><li>Unlimited local projects</li><li>No account, no telemetry</li></ul>
|
||||
<div class="founding">forever free</div>
|
||||
</div>
|
||||
<div class="pass early">
|
||||
<div class="tier">Early Access</div>
|
||||
<div class="price"><span class="cur">$</span><span class="num">6</span><span class="per">/mo</span></div>
|
||||
<div class="for">solo devs juggling several agent projects</div>
|
||||
<ul><li>Hosted goal + run-log viewer</li><li>Cross-machine goal sync</li><li>Email digest on run failures</li></ul>
|
||||
<div class="founding">founding price · waitlist, not billed yet</div>
|
||||
</div>
|
||||
<div class="pass team">
|
||||
<div class="tier">Team</div>
|
||||
<div class="price"><span class="cur">$</span><span class="num">24</span><span class="per">/mo</span></div>
|
||||
<div class="for">teams sharing Codex objectives across a repo</div>
|
||||
<ul><li>Shared goals, per-teammate run history</li><li>Slack alert when a run fails</li><li>Audit log export</li></ul>
|
||||
<div class="founding">founding price · waitlist, not billed yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="h">rules of the game</h2>
|
||||
<ul class="rules">
|
||||
<li><span class="flag">1 goal</span> <span class="desc">One objective per project at a time. Not a task list, not a scheduler, not a replacement for your issue tracker.</span></li>
|
||||
<li><span class="flag">no force</span> <span class="desc">The injected text is plain, visible user context — it carries no special authority inside Codex. A model can still choose to ignore it, same as any other instruction.</span></li>
|
||||
<li><span class="flag">no grading</span> <span class="desc">This only guarantees the goal is faithfully re-stated every run. It does not inspect diffs or grade progress — pair it with <a href="https://finishline-supervisor.yolopreneur.lol/">finishline-supervisor</a> for that.</span></li>
|
||||
<li><span class="flag">plain json</span> <span class="desc"><code>$CODEX_GOAL_HOME</code> is ordinary local storage with your file permissions, no encryption. Don't put secrets in goal text.</span></li>
|
||||
<li><span class="flag">local lock</span> <span class="desc">Locking is advisory and local-filesystem only — it doesn't coordinate over NFS/SMB or across machines.</span></li>
|
||||
<li><span class="flag">your flags</span> <span class="desc"><code>run</code> forwards whatever <code>codex exec</code> flags you give it, including dangerous ones if you ask for them. This wrapper adds or removes none of Codex's own safety behavior.</span></li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="bar">
|
||||
<div class="score-tape" aria-hidden="true">
|
||||
<span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span>
|
||||
<span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span>
|
||||
</div>
|
||||
<div class="links">
|
||||
<a href="codex_goal.py" download>download the free CLI ↓</a>
|
||||
<a href="https://github.com/openai/codex/issues/26966">upstream issue #26966</a>
|
||||
<a href="README.md">full README</a>
|
||||
<a href="/">notplanned</a>
|
||||
</div>
|
||||
<p class="fine">codex-goal is an independent wrapper around the <code>codex</code> CLI. Not affiliated with or endorsed by OpenAI. "Codex" is referenced only to describe compatibility. DSEG7 Classic font © keshikan, SIL Open Font License 1.1 (<a href="fonts/OFL.txt">license</a>).</p>
|
||||
</footer>
|
||||
</div>
|
||||
<script>
|
||||
if(new URLSearchParams(location.search).get("joined")==="1"){
|
||||
var j=document.getElementById("join");
|
||||
if(j){j.classList.add("joined");}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 131 KiB |
@@ -1,48 +0,0 @@
|
||||
# Context Curator
|
||||
|
||||
A browser-only manual editor for a completed exploratory span in an agent transcript. Inspired by [openai/codex#14747](https://github.com/openai/codex/issues/14747), closed as not planned. It keeps the surrounding context and replaces one contiguous span with a conclusion you write.
|
||||
|
||||
## Install and use
|
||||
|
||||
Open `site/index.html` directly in a modern browser. No installation, dependencies, account, server, or model calls. The hosted page offers that same complete HTML as a free offline download. The initial interactive example is an **illustrative** debugging transcript, not a recorded execution or performance benchmark.
|
||||
|
||||
1. Try the sample, open a JSON file, or paste JSON and press **Load this JSON**.
|
||||
2. Choose first and last messages (1-based, both inclusive). Write the root cause, supporting evidence, unresolved questions, and next action.
|
||||
3. Press **Replace selected span**. Read the complete message diff and expandable full JSON views. Size measurement compares compact UTF-8 JSON bytes, including conclusion metadata, not tokens, costs, or Markdown file size. A long conclusion can make the result larger.
|
||||
4. Download the Markdown continuation packet or curated JSON. **Save untouched original** downloads the exact loaded text, including whitespace. **Undo replacement** restores the previous working result (one level). Replacements always target the loaded original, never accumulate silently.
|
||||
5. Review the packet before manually handing it to a fresh session. All messages are quoted as JSON in Markdown, with fences safe for embedded backticks. No commands or transcript HTML are executed.
|
||||
|
||||
## JSON transcript format (version 1)
|
||||
|
||||
```json
|
||||
{"version":1,"title":"Optional metadata","messages":[
|
||||
{"id":"m1","role":"system","content":"Keep the requirements."},
|
||||
{"id":"m2","role":"tool","content":"Exploratory evidence."},
|
||||
{"id":"m3","role":"user","content":"Next task."}
|
||||
]}
|
||||
```
|
||||
|
||||
Required: `version: 1`, 1–500 messages, each with a unique nonempty string `id`, `role` in `system|user|assistant|tool`, and string `content`. Limit: 2 MiB UTF-8 source JSON. Additional top-level and message fields survive. Retained messages are structurally identical; formatting in curated JSON is normalized. The original source string is preserved separately, byte-for-byte for valid UTF-8 input. Invalid imports leave the last loaded original and result available. Files must be valid UTF-8.
|
||||
|
||||
`sample.json` is the example. The manual replacement is an assistant message tagged `curator.kind = "manual-conclusion"`, with `replacedIds` for the entire selected span and a unique generated ID. Selected messages and their metadata are absent from the curated export; they remain in the original. Selection may include any role: review carefully before removing requirements or instructions.
|
||||
|
||||
## Privacy and limits
|
||||
|
||||
Transcript processing stays in browser memory. No fetch, telemetry, third-party fonts/scripts, local storage, session edits, or configuration access. Closing or reloading loses working state; download the original and exports first. Only the optional signup form posts email, plan, and note to the existing `/api/signup` service. Offline signup is unavailable. `?joined=1#join` shows the thank-you state.
|
||||
|
||||
This does not import native Codex/Claude session files, automatically summarize, resume a session, count model tokens, or guarantee a good conclusion. Convert an explicit transcript export into the documented format yourself. It is a handoff aid, not a compaction API. Do not include sensitive context in the signup note. A malicious transcript can still contain misleading instructions: quoted Markdown is not an agent security boundary.
|
||||
|
||||
## Develop and verify
|
||||
|
||||
From this directory:
|
||||
|
||||
```sh
|
||||
python3 build.py
|
||||
node --test tests/core.test.cjs
|
||||
# Browser checks require Playwright installed outside this repo:
|
||||
NODE_PATH=/tmp/context-curator-browser/node_modules node tests/browser.cjs
|
||||
```
|
||||
|
||||
`build.py` embeds `core.js`, the sample, and `site/app.js` into `site/template.html`, writes the standalone `site/index.html`, and copies the sample and README beside it. Libre Baskerville Latin regular/italic WOFF2 (Fontsource package, SIL OFL 1.1 in `site/fonts/OFL.txt`) is vendored and embedded as data URLs so the single-file download keeps its typography offline. No build dependency beyond Python 3; core tests require Node 18+. Browser tests accept `CHROME_PATH` (default hb-5 Chrome), `PAGE_URL` (default file://), and `EVIDENCE_DIR` (default /tmp/context-curator-evidence). They cover desktop/phone overflow, undo, imports, exports, escaping, form payload and thank-you state, and screenshots. Run `python3 tests/signup.py /path/to/existing/server.py` for signup acceptance: it imports the existing handler with temporary data, never the real signup list. Checked launch evidence is in `evidence/`; native macOS browser verification was blocked by this seat sandbox (Mach bootstrap permission denied). Node core tests did run on macOS. Container Chromium passed both file:// and a local HTTP `/v/context-curator/` route, including reopening the downloaded HTML offline. The public route is deliberately not polled: publication happens after the issue reply.
|
||||
|
||||
Launch is confined to this venture directory; the factory runner publishes `site/` at `/v/context-curator/` after the issue reply. No shared deployment files are changed.
|
||||
@@ -1,83 +0,0 @@
|
||||
'use strict';
|
||||
const $ = id => document.getElementById(id);
|
||||
let original = SAMPLE_TEXT;
|
||||
let applied = null;
|
||||
let undoState = null;
|
||||
let generation = 0;
|
||||
const sampleConclusion = 'Root cause: preview_cache is keyed only by article ID, has no expiry, and is not invalidated on save. Invalidate that key after a successful save; verify with a save-then-preview regression test. Database writes and browser caching were ruled out. Keep the API response unchanged.';
|
||||
function report(message) { $('status').textContent = message; }
|
||||
function downloadsEnabled(value) {
|
||||
$('packet').disabled = !value; $('result').disabled = !value;
|
||||
}
|
||||
function renderSource(text) {
|
||||
const data = Curator.parse(text);
|
||||
original = text; applied = null; undoState = null;
|
||||
$('source').value = text;
|
||||
for (const id of ['first', 'last']) {
|
||||
$(id).replaceChildren(...data.messages.map((m, i) => {
|
||||
const option = document.createElement('option'); option.value = i;
|
||||
option.textContent = `${i + 1} · ${m.role} · ${m.id}`;
|
||||
return option;
|
||||
}));
|
||||
}
|
||||
$('first').value = Math.min(2, data.messages.length - 1);
|
||||
$('last').value = Math.min(7, data.messages.length - 1);
|
||||
$('conclusion').value = text === SAMPLE_TEXT ? sampleConclusion : '';
|
||||
$('before').textContent = JSON.stringify(data, null, 2);
|
||||
$('after').textContent = 'Apply a conclusion to see the complete result.';
|
||||
$('diff').textContent = 'Every message will appear here, including unchanged messages.';
|
||||
$('measure').textContent = `${Curator.bytes(JSON.stringify(data)).toLocaleString()} original JSON bytes`;
|
||||
$('undo').disabled = true; downloadsEnabled(false);
|
||||
report('Original preserved in memory. Nothing has been uploaded.');
|
||||
}
|
||||
function apply() {
|
||||
try {
|
||||
const next = Curator.curate(original, Number($('first').value), Number($('last').value), $('conclusion').value);
|
||||
undoState = applied; applied = next;
|
||||
renderResult(); $('undo').disabled = false;
|
||||
report('Span replaced in the working copy. Review the full diff before exporting.');
|
||||
} catch (e) { report(e.message); }
|
||||
}
|
||||
function renderResult() {
|
||||
if (!applied) {
|
||||
$('after').textContent = 'Working copy restored to the original.';
|
||||
$('diff').textContent = $('before').textContent;
|
||||
$('measure').textContent = `${Curator.bytes(JSON.stringify(Curator.parse(original))).toLocaleString()} original JSON bytes`;
|
||||
downloadsEnabled(false); return;
|
||||
}
|
||||
$('after').textContent = JSON.stringify(applied.result, null, 2);
|
||||
$('diff').textContent = applied.diff;
|
||||
const percent = (100 * applied.saved / applied.beforeBytes).toFixed(1);
|
||||
$('measure').textContent = `${applied.beforeBytes.toLocaleString()} → ${applied.afterBytes.toLocaleString()} JSON bytes · ${percent}% ${applied.saved >= 0 ? 'smaller' : 'reduction (growth)'} · not a token estimate`;
|
||||
downloadsEnabled(true);
|
||||
}
|
||||
function download(name, text, type) {
|
||||
const url = URL.createObjectURL(new Blob([text], {type}));
|
||||
const a = document.createElement('a'); a.href = url; a.download = name;
|
||||
a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
$('apply').addEventListener('click', apply);
|
||||
$('undo').addEventListener('click', () => { applied = undoState; undoState = null; renderResult(); $('undo').disabled = true; report('Last replacement undone. Original unchanged.'); });
|
||||
$('load').addEventListener('click', () => { try { renderSource($('source').value); generation++; } catch(e) { report(e.message); } });
|
||||
$('sample').addEventListener('click', () => { generation++; renderSource(SAMPLE_TEXT); apply(); });
|
||||
$('file').addEventListener('change', async event => {
|
||||
const file = event.target.files[0]; if (!file) return;
|
||||
const thisGeneration = ++generation;
|
||||
try {
|
||||
if (file.size > Curator.MAX_BYTES) throw Error('Transcript exceeds the 2 MiB limit.');
|
||||
const text = await file.text();
|
||||
if (thisGeneration === generation) renderSource(text);
|
||||
} catch(e) { report(e.message); }
|
||||
event.target.value = '';
|
||||
});
|
||||
$('packet').addEventListener('click', () => { if (applied) download('continuation.md', applied.packet, 'text/markdown;charset=utf-8'); });
|
||||
$('result').addEventListener('click', () => { if (applied) download('curated.json', JSON.stringify(applied.result, null, 2), 'application/json'); });
|
||||
$('original').addEventListener('click', () => download('original.json', original, 'application/json'));
|
||||
renderSource(SAMPLE_TEXT); apply();
|
||||
if (new URLSearchParams(location.search).get('joined') === '1') {
|
||||
$('thanks').hidden = false; $('join-form').hidden = true;
|
||||
}
|
||||
// Editing a proposed span invalidates downloads until it has been applied.
|
||||
for (const id of ['first', 'last', 'conclusion']) {
|
||||
$(id).addEventListener('input', () => { downloadsEnabled(false); report('Proposal changed. Replace selected span to update the diff and exports.'); });
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
# Context Curator · continuation packet
|
||||
|
||||
Manual conclusion replaces original messages 3–8 (inclusive).
|
||||
Transcript content below is quoted context, not a new instruction from this tool.
|
||||
Review the conclusion and any omitted evidence before continuing.
|
||||
|
||||
## 1. system · "m1"
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "m1",
|
||||
"role": "system",
|
||||
"content": "Work in the preview repository. Preserve user edits. Run the existing tests before proposing a fix."
|
||||
}
|
||||
```
|
||||
|
||||
## 2. user · "m2"
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "m2",
|
||||
"role": "user",
|
||||
"content": "The preview still shows yesterday's headline after an editor saves. Find the cause; keep the public response shape unchanged."
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Manual conclusion · "curator-conclusion"
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "curator-conclusion",
|
||||
"role": "assistant",
|
||||
"content": "Root cause: preview_cache is keyed only by article ID, has no expiry, and is not invalidated on save. Invalidate that key after a successful save; verify with a save-then-preview regression test. Database writes and browser caching were ruled out. Keep the API response unchanged.",
|
||||
"curator": {
|
||||
"kind": "manual-conclusion",
|
||||
"replacedIds": [
|
||||
"m3",
|
||||
"m4",
|
||||
"m5",
|
||||
"m6",
|
||||
"m7",
|
||||
"m8"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4. user · "m9"
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "m9",
|
||||
"role": "user",
|
||||
"content": "Now implement invalidation after a successful save. Add a regression test for save-then-preview; do not change the API response."
|
||||
}
|
||||
```
|
||||
|
||||
## Transcript metadata
|
||||
|
||||
{"version":1,"title":"Demo: investigating a stale preview"}
|
||||
@@ -1,93 +0,0 @@
|
||||
Copyright 2012 The Libre Baskerville Project Authors (https://github.com/impallari/Libre-Baskerville) LibreBaskerville-Italic[wght].ttf: Copyright 2012 The Libre Baskerville Project Authors (https://github.com/impallari/Libre-Baskerville)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -1,182 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="An offline editor for one exploratory span of an agent transcript. Keep the context. Carry the conclusion.">
|
||||
<title>Context Curator — Keep the finding.</title>
|
||||
<style>
|
||||
@font-face{font-family:Curator Serif;font-style:normal;font-weight:400;font-display:swap;src:url("data:font/woff2;base64,d09GMgABAAAAAE6MABAAAAAAs2gAAE4pAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGn4b/HwciUAGYD9TVEFUSACFBBEICoG6AIGTOguEHgABNgIkA4g4BCAFhR4HiW4MBxvsnCVsVzNuBxC38/02NTKQ20GgUPV+o5CkMNGW/f/35GTIYP+BmZpVkMWJI07kbmQMD3rJkSiYzpz0ahIWBkzmuN4eXcz5VuOgoYhOEoRCKtZ241Dvugcv8nPficSCoEKlVEosbcPFNCwKmZYpVbDqfX3d+DmX12O//OP9l1IdmC2KKCyTKblnEeDOjqJDTiJq7Xl7Zu8jOQoLS6wYFAAqQkWsgXwiVCrCIOnE0dvjac5+3u5moxDHA4EE8aIhQQrFpRBMg1uFKu3VhIM2taPwf0uhqN0VTqUnWrE7r9gOT3P6DwgUiQkRiFzu4kKIC65FilipuFOZ1ERmWrGptdvv55/6g9+5u+9LkoZRhAmkTZMGLul/88092+pfRjSZJZWMTyKPCpGIh/5db2pfRG53CHIM5yUNDuCdlkK9T8o6gmVmA5XevG59FV2xVXXvKhGbb+JjzgjBogRi2AqqcBnICU+ghdQ2JRwGQYLbu/8mt7SMTZ0tCtkyJikCT32alr+WM7b17ae3Cc/et5GJ8K2yTArI4a2dsaXnAeWvkLVVP4TpNZ+CDogfJic2wB3l1GZSnimQqQRu5BKQVutElzpQ9gEX9LG+1tdBJBuer70/9X2HpeyjXdoD+dWDropehvGLkAiHL+2yVPV5tNSuSlfISCYqwgYZx0RGZSKRsSbjI5QPD/+tfWnnLpD7QpYcldNBTRad+zS7+1JLVOn8oKp0qMMdhuNRyMjopAMoR62MBhKKUFib+bdp2c7334nlQ8/CAeuIHejtTbgiqK5MUtPM/3/0Z+brr8h2LI1lkNZZyWO/tU72GrSgsazdkeyL7UNTgEFLR8hdAKgELlM0eVeUKVsA6FK0bdrAv3vl90+C7eB3KJcSJITnI4QOTk7uhlWn3e8fhrJIKVJERDwJIeRCCMX9yt62VCsJvBb14YS1uh7/+t9y1gd1Z4n4B1JjetrDKfEeY5rvF5Pfo2Yy9UAwEFmu9r8pMLoAXAjDhuhAeMSSOJBK4swPiVAM0dBAylVAqrRBfvQj5Gd/wf3jKRyCAXsBm2EwP/sT+NsTPElofnbLHfc8goCNVJrgp31GJYH0+trCMhDdkltfCVIowKQEwtb+s3ru5p/aSrD4S++B0a1lFQ9uAHK10a3F+ecIWLPR5vHLOO88DrL61VgQNNpth3ofB0+PUgWypEo4/X6UEP5UtOSEeJK8PX6GdF5rz/hHTw97nRsf6oPxmy6Ox7ownGxPknaPANOm6ocL03b76vxIbGwJlMsXIBhQ746JXSwFR01ZrzUCY6HXxdP8PfFht/upm/c+78PWor7cT4Sz08e71mAXOzP0VEfa3665W2ut1VdZSVPewhmlFFdEQeK3btzcc00aQsiZzogcFjEQNlLqP4/9+fJ9v0FJnH03ZmQSxIooa8jldtuDYP/ELMkTnOX6Gg64MPzsQ7g3zvvcdbuWtNG+sR++s1vBAmPmM1mdZwi6dW/bqXN2zbc4z2Xe4wg+NJFav7nuU8yzk/3+Gf96N3If8jHznbGL5WmzKWeL1QiUrVUyJisBMCLs9rFlEznkkn+xamLA8yf3pHt4podmukdukxvkKrnywcWQw33YwiOVvLl5LixCbAueLC7JuU/JwYfUV78eiNOXcjL/qpONFP/vrYnaejrWgaF72l57jWH1eun2SUFZpbqhCUUVkv9cVVqVJyx4sS6jhi8TEfDa01f499SHbvvJzdhc86EP9fKaZSi+wImQ/2UuuVv6K3QwBMjsgQcetAB2cWUhgeuW5szL4Y6MeCR0UZV15022fc5WHUfwI5Wb9l4q5nRkvbkP+ZghYq3NMcU1tkpRVVxM74GNdOJHPCxoZsTo1BX4Ki5WPMJ4C4qxUUKCIBmKhZCZBEm81yRZ26VZfnwxGwZsPB0hCYrw1//yFIYBH8tl4MBoESAbYRwD08s353Gf+Xw1fz6Yi3IrvCMr4TsybKOlJl7w57bbclvAopvkQHfOOvlHKlyvs8BHhhAmLKNDellLguBwyGS9DEFS0TFO3QTaOsnrGLYzzsKq1JEplDStdDNKmlW6OcXNK2FBcYv402wuWUaDRG0eHFz3qEY74sH3On9mMG1H2qEbXXnqkvy1ngmZc8x3saO+Nstf/TVv0ToC6Y3M62pHO9vV7va0t+72tb+eeutroMGudLWhhhvpeqONNdFk08228eWu9m5f9FW4HUI7Sd0EZAcDs9vIHgRhGjartMS3HLS9QUF6uuGAoVUQUXiTnpX5qjB19f2dtF3UHCrM3qj7cPs1PiyCjARA6JrU3QHsNcMFN/JOowGMB73rsF1BieZe2H7K8ILPt+immzQyMpwM4eh4bMA7mC5E95OMP0yHaKZV1uhuumBYCfndcfx21Oozve71iXZ8ADEY1xWhqzI0HAlgPGETXUwy/Jz2Bemr2bwUPVRoehl9RmvCZJDvgJ4o3JJZbt8VZVFtBOVm7Bxf1T5DL6JPtKsRuPGCTFhMWouYEqxjKS/FhrUh+ZzD87oTYemmSB4mxJwM48gKBrOHtE8F2Wt9zBJimXnzfD6fn/3U24QPUMlIX1Qv7LmHmrzHI47ZZtAQFQiBKCSgKCscCLh2zTwQIiQ3OqYkHT+uzJfNKerRoyIPT6UOgbJiceoLf/ruElDL8kAfpSnA1+PSTY6nVAvUtVFJpIBZlAKBA92aKgTdF3q5fNhPdmwR9a2/oW3choKnzWfmXut4U0hb92VNmqA8JgGl55h6mQBAOancJJhN10tDFLEa4BSv37TiQK9uI0DlgNZTpo/acEihho2Cww9fUloVAK0wBlURATF2BBxpxiaDikVLmNdTsLVainRMr4oZQCkM7hbtVgFQ/HDLyzJbS5M3AawjigNwx1N9UEG0R9qhk0C/TX4cBWVHCJyfYvF6O3HuvA6Mi4/qd6r+t86SkamOxaSAuJwOX+9y4BkUrzooi6oYZfHwdrHyq/nfQxckeXPSXm1snTBCKwkTNtOOJBvmQ6YHYg4OyheltU4Prnmhg8saZ9IbElBy5GGDk+MK6rD8wcdYYqXx0nlERW8U3OGwSHBBDj3PuRmgXqTOtKCaRT2NkdmqZKVNJafaUhQDLs2m6NDtQ6JY/+ZPtue17wfUfAAAQNUnW34PalZJyUCemh+MS/rdUUDZPXToHwd+E7Noq1MAw4/925baswW1FAB90dd+ax4ArOkffwH1+s9pX9ZTR9Z4elz/xm0MAIDvzFvIoCrNqrVpXMFSdyutjj1Ogz96RzjTGGmfNAvCxl04qFA6Vcord/Py4RoOQjjNqhO+UGL4eKURJKzIa21fvHKXAIxT3ZNIpBkow9D2u+ImdEZXz6K2vdVehmqEeihuzALqhMOGzd1oTo2Z51YAs4uukwY2hDCtORzCi70CaUPlMlNosHTihrp2bZaL2gLLromwFyUMbaNkEH19PF/QEpYcaLZRIgXFDru2BfGWvDDOmnZkD5jobkJidIjVsoIyDG/q37zDn9dvq91LDZ2bUFQssoHjOd02v1AptIDS81tHlAWiMFOjQnFmtRgG4vkBqCUudFYjDcLex4C6z7EX45ygwDDPVVZKsEKCYuKOGrObaRVdQRPz8JgeFWsFqVkgnDJjjx2jzCbcDiVpuYPAyJJLGZkRW5UOQ3PlhhDUsDrPwWkcW4w68Wq8nM+aCXpRoOJ8Q5g9fI0NcyBWzUkIjsUkk+/FJBoFUwHjsDpPqPJwvDiXLkwMAfbRLRfkYMBF2lRu1Sd9vBIHUO3ytL0shXZubBe7JgubQpnrJafiLqQea13RmjZpybE/4GEaTty8vDg8zPztBlDWTmlfVvw9JYAhT8ThjU0F55pf+RDFWF2QQBun9j3OCygFSSAQ5iI1pDzaMhjxcEmVYssAas0xPVCJvhS2wyICTkfiEJkkrH8+tWInnuRT0tKQzRGi4jkaRNdIROmKnTlcLA/sDpnacLp3LU7iRRaMH8qGc0I6pw50r3hI1NY4xy10HiDMf+2eDiYuERY9Ejxm7Blw5smSFy+OVMI4iZDCVyq1CBkyRctWKla5Cumq1MvUqFG+ZlsV6HVWhSuG1JiwbADGF8LAxsQkxcKCsHHw6dCBcIkhevTo02cCkZAwZ0YKMSejQ84ejQNHiDNnyAYb6HLhQsCVJwYvXgyoeBPy4cOQryCkYMEshAiFCROGK1w4XIQYmFixCHHiGIsXjydBCqZUqZjSqJnJkIEnUyaRLDnocpXAlSploUwZrnLl6CpU4KhUiadKFbFq9VgaNTLRrJWeNm0k2nUi9Opj5IyzZLS0jFwxxNSESUamTLE2bZqNGTOszZplY84cuXnzLC1YILfoAtiSSxAYPwgDExMTokOHIS4uQzzKIsPMGAADKMkGGDhIROABLBDPswcJZEIcJME0yIJkgEm6gAVBl4RBMBODDfWc5h5aQwmkQxziUB9azONBbbIYCChBwkDDxA6aMj7AkA0Z0AwaAE6yHaxh0iXh0CnxYIqEw1aGBx5yGAI55EMOlEAplEApFEIRFELRh04MSSgMEADeNWn3OzQxnUyhHB73JBhWBpmRmUL/hn6IPhXdSw2lPDuBbD3NTmVQvqScolRReK4KqYj4hPALYfYjTiQUlOIT0vA/4G/hT+Bup+qq4JbituMycNGEFsfD/hEp2M91V3V110lVmB88ypVZLyYFo8QQs/5g32Yv/rkPlmsmOSOYIUxgGVpczBc6XUIMdbo0nTrqF/p9uvsoedp/5C/kx+RI2uW0I2n70zLine truncated
|
||||
:root{--paper:#f3efe4;--ink:#202f2b;--muted:#54605a;--red:#a33624;--rule:#a7aa9e;--light:#fcf9f0}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--paper);color:var(--ink);font-family:'Curator Serif',Georgia,serif;font-size:18px;line-height:1.5}body:before{content:'';position:fixed;inset:0;pointer-events:none;opacity:.13;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.7' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Cpath filter='url(%23n)' opacity='.22' d='M0 0h120v120H0z'/%3E%3C/svg%3E")}a{color:inherit;text-underline-offset:4px}a:hover{color:var(--red)}main,.masthead,footer{max-width:1220px;margin:auto;padding:0 44px}.masthead{padding-top:23px}.edition,.eyebrow,label,button,.utility,summary,select,input,textarea,.measure{font-family:Arial,Helvetica,sans-serif}.edition{display:flex;justify-content:space-between;gap:15px;font-size:11px;text-transform:uppercase;letter-spacing:1.8px;border-top:1px solid var(--ink);border-bottom:1px solid var(--ink);padding:8px 0}.mast-title{display:flex;justify-content:space-between;align-items:baseline;border-bottom:5px double var(--ink);padding:15px 0 12px}.name{font-size:clamp(30px,4.4vw,58px);font-weight:bold;letter-spacing:-2px}.issue{font-style:italic;font-size:16px}.lead{display:grid;grid-template-columns:2fr 1fr;gap:40px;padding:40px 0 38px;border-bottom:1px solid var(--ink)}.eyebrow{font-size:11px;text-transform:uppercase;letter-spacing:2px;font-weight:bold;color:var(--red);margin:0 0 17px}h1{font-size:clamp(50px,6.2vw,78px);line-height:.98;letter-spacing:-3px;font-weight:normal;margin:0 0 25px}h1 em{color:var(--red)}.dek{max-width:590px;font-size:22px;line-height:1.4;margin:0 0 24px}.cta{display:inline-block;padding:13px 22px;background:var(--ink);color:var(--light);font:bold 14px Arial,Helvetica,sans-serif;text-decoration:none}.cta:hover{background:var(--red);color:white}.lead aside{border-left:1px solid var(--rule);padding-left:30px}.lead aside h2{font-size:27px;line-height:1.2;margin-top:0}.lead aside p{font-size:17px}.margin-note{font-style:italic;color:var(--muted)}.section-head{display:flex;justify-content:space-between;gap:24px;align-items:baseline;padding:27px 0 16px}.section-head h2{font-weight:normal;font-size:34px;margin:0;letter-spacing:-1px}.utility{font-size:12px;color:var(--muted)}.tool{border-top:4px solid var(--ink)}.workbench{display:grid;grid-template-columns:1fr 1.3fr;gap:32px;padding-bottom:22px}.editor{border-right:1px solid var(--rule);padding-right:30px}label{display:block;font-size:12px;font-weight:bold;margin:15px 0 6px}textarea,input,select{font-size:14px;line-height:1.45;color:var(--ink);background:var(--light);border:1px solid var(--rule);padding:10px;width:100%;border-radius:0}textarea{resize:vertical}select{min-width:0}.range{display:grid;grid-template-columns:1fr 1fr;gap:12px}.actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}button{font-size:12px;font-weight:bold;border:1px solid var(--ink);color:var(--ink);background:transparent;padding:10px 13px;cursor:pointer;min-height:40px}button:hover{background:var(--ink);color:var(--light)}button:disabled{opacity:.45;cursor:default}button.primary{background:var(--ink);color:var(--light)}:focus-visible{outline:3px solid var(--red);outline-offset:3px}pre{font:12px/1.6 'Courier New',monospace;white-space:pre-wrap;overflow-wrap:anywhere;margin:0;max-height:440px;overflow:auto;background:var(--light);padding:17px;border:1px solid var(--rule)}.measure{padding:12px 0;font-size:14px;font-weight:bold;color:var(--red);border-bottom:1px solid var(--rule)}.diff-key{font-size:14px;color:var(--muted)}#status{font:13px/1.5 Arial,Helvetica,sans-serif;min-height:40px}summary{cursor:pointer;font-size:13px;padding:12px 0}details pre{max-height:300px}input[type=file]{font-size:12px}.notes{display:grid;grid-template-columns:repeat(3,1fr);gap:30px;padding:27px 0 35px;border-top:1px solid var(--ink);border-bottom:1px solid var(--ink)}.notes article+article{border-left:1px solid var(--rule);padding-left:25px}.notes h3{font-size:23px;font-weight:normal;margin:0 0 12px}.notes p{font-size:16px;margin:0}.number{font-style:italic;color:var(--red)}.price-row{display:grid;grid-template-columns:170px 1fr 1.4fr;border-top:1px solid var(--rule);gap:25px;padding:18px 0;font-size:16px}.price-row strong{font-size:21px;font-weight:normal}.join{display:grid;grid-template-columns:1fr 1.2fr;gap:45px;border-top:4px solid var(--ink);padding:25px 0 35px;margin-top:25px}.join h2{font-size:38px;line-height:1.1;font-weight:normal;margin:0 0 17px}.join p{font-size:17px}.small{font-size:13px!important;color:var(--muted)}#thanks{border-left:3px solid var(--red);padding-left:20px}footer{padding-top:18px;padding-bottom:28px;border-top:1px solid var(--ink);font-size:13px;display:flex;justify-content:space-between;gap:20px}[hidden]{display:none!important}@media(maLine truncated
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="masthead"><div class="edition"><span>A notplanned publication · No. 003</span><span>Local tools / better handoffs</span></div><div class="mast-title"><span class="name">Context Curator</span><span class="issue">A field guide to carrying less.</span></div></header>
|
||||
<main>
|
||||
<section class="lead"><div><p class="eyebrow">For developers with a long conversation behind them</p><h1>Keep the finding.<br><em>Cut the detour.</em></h1><p class="dek">Your agent explored six dead ends. The next session needs the conclusion — and everything that still matters around it.</p><a class="cta" href="#join">Join the early access list →</a><p class="small">Free local editor below. No account needed.</p></div><aside><h2>A smaller handoff,<br>with the evidence in view.</h2><p>Select one stretch of a JSON transcript. Write the conclusion yourself. Compare every message, then download a Markdown continuation packet.</p><p class="margin-note">The original stays intact. Undo is one click. Your transcript never leaves this browser.</p><p class="small">Inspired by <a href="https://github.com/openai/codex/issues/14747">openai/codex #14747</a>, closed as “not planned.” This is a manual, standalone workaround, not native session compaction.</p></aside></section>
|
||||
<section class="tool" id="editor" aria-labelledby="editor-title"><div class="section-head"><h2 id="editor-title">The working edition</h2><span class="utility">Illustrative debugging transcript · editable, not a benchmark</span></div>
|
||||
<div class="workbench"><div class="editor"><p class="small">The sample removes messages 3–8, retaining the requirements and next task. Try your own transcript using the documented version 1 format.</p><label for="file">Open a JSON transcript (2 MiB / 500 messages maximum)</label><input id="file" type="file" accept=".json,application/json">
|
||||
<details><summary>Paste JSON / inspect the sample format</summary><label for="source">Transcript JSON</label><textarea id="source" rows="10" spellcheck="false"></textarea><button id="load" type="button">Load this JSON</button><p class="small">Required: <code>{"version":1,"messages":[{"id":"m1","role":"user","content":"…"}]}</code>. Roles: system, user, assistant, tool. Unique string IDs. Extra metadata is retained.</p></details>
|
||||
<div class="range"><div><label for="first">First message (inclusive)</label><select id="first"></select></div><div><label for="last">Last message (inclusive)</label><select id="last"></select></div></div>
|
||||
<label for="conclusion">Your replacement conclusion</label><textarea id="conclusion" rows="7" maxlength="100000" spellcheck="false"></textarea>
|
||||
<div class="actions"><button class="primary" id="apply" type="button">Replace selected span</button><button id="undo" type="button">Undo replacement</button><button id="sample" type="button">Reset to sample</button></div><p id="status" role="status" aria-live="polite"></p>
|
||||
<div class="actions"><button id="packet" type="button">Download Markdown packet</button><button id="result" type="button">Download curated JSON</button><button id="original" type="button">Save untouched original</button></div><p class="small">Offline copy: <a href="index.html" download="context-curator.html">download this complete editor</a>. Open the saved HTML in your browser. <a href="sample.json" download>Sample JSON</a> · <a href="demo-continuation.md" download>Demo packet</a> · <a href="README.md">Usage & limits</a></p></div>
|
||||
<div><div class="measure" id="measure" aria-live="polite"></div><p class="diff-key">Complete message diff · − removed · + manual conclusion · unmarked retained.<br>All message fields are shown. No hidden context.</p><pre id="diff" tabindex="0" aria-label="Complete before and after diff"></pre><details><summary>Complete original JSON</summary><pre id="before" tabindex="0"></pre></details><details><summary>Complete curated JSON</summary><pre id="after" tabindex="0"></pre></details></div></div></section>
|
||||
<section class="notes" aria-label="How it works"><article><h3><span class="number">01 /</span> Mark the detour.</h3><p>Import a transcript you have explicitly exported or converted. Pick one contiguous span. Messages outside it stay unchanged, including their metadata.</p></article><article><h3><span class="number">02 /</span> Write what survived.</h3><p>State the finding, evidence, unresolved questions, and next step. Nothing is summarized by a model. You decide what can safely be left behind.</p></article><article><h3><span class="number">03 /</span> Review & carry forward.</h3><p>Read the full diff. Download the quoted Markdown context for a fresh session. No live agent sessions or configuration are modified. Byte reduction is not a token or cost promise.</p></article></section>
|
||||
<section aria-labelledby="pricing-title"><div class="section-head"><h2 id="pricing-title">An honest price list.</h2><span class="utility">Paid ideas are early access. No checkout, no billing.</span></div><div class="price-row"><strong>Local edition</strong><span>$0 · available today</span><span>Complete offline editor, sample, undo, diff and exports. No signup required.</span></div><div class="price-row"><strong>Founding workshop</strong><span>$49 one-time · proposed founding price</span><span>Early access idea: a guided transcript-conversion and handoff review. Register interest; not yet delivered.</span></div></section>
|
||||
<section class="join" id="join" aria-labelledby="join-title"><div><p class="eyebrow">The next edition</p><h2 id="join-title">Less history.<br>More continuity.</h2><p>Get release updates, or signal interest in a guided workshop. The free editor is already yours.</p><p class="small">We use your email for Context Curator updates and your requested early access follow-up; we never share it. Do not put transcript content or secrets in the note.</p></div><div><form id="join-form" method="post" action="/api/signup" enctype="application/x-www-form-urlencoded"><input type="hidden" name="venture" value="context-curator"><input type="hidden" name="website" value=""><label for="email">Email</label><input id="email" name="email" type="email" required autocomplete="email" maxlength="254"><label for="plan">Your edition</label><select id="plan" name="plan" required><option value="free">Free local edition · release updates</option><option value="workshop">Founding workshop · $49 proposed, early access</option></select><label for="note">What would help? (optional)</label><textarea id="note" name="note" rows="2" maxlength="500"></textarea><div class="actions"><button class="primary" type="submit">Join the early access list →</button></div><p class="small">Signup requires the hosted page and a connection. The editor works offline.</p></form><div id="thanks" hidden role="status"><h3>You're on the list.</h3><p>Thanks for joining. Keep using the free local edition while we prepare the next release.</p><a href="#editor">Back to the editor ↑</a></div></div></section>
|
||||
</main><footer><span>Context Curator · made at notplanned</span><span>No model calls. No trackers. No live-session access. Keep your original.</span></footer>
|
||||
<script>
|
||||
/* Context Curator: no I/O; the caller owns the untouched source text. */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
const MAX_BYTES = 2 * 1024 * 1024;
|
||||
const bytes = text => new TextEncoder().encode(text).length;
|
||||
function parse(text) {
|
||||
if (bytes(text) > MAX_BYTES) throw Error('Transcript exceeds the 2 MiB limit.');
|
||||
let data;
|
||||
try { data = JSON.parse(text); } catch { throw Error('Invalid JSON. The original has not changed.'); }
|
||||
if (!data || data.version !== 1 || !Array.isArray(data.messages) || !data.messages.length || data.messages.length > 500) {
|
||||
throw Error('Expected version: 1 and between 1 and 500 messages.');
|
||||
}
|
||||
const ids = new Set();
|
||||
for (const m of data.messages) {
|
||||
if (!m || typeof m.id !== 'string' || !m.id.trim() || ids.has(m.id) ||
|
||||
!['system', 'user', 'assistant', 'tool'].includes(m.role) || typeof m.content !== 'string') {
|
||||
throw Error('Each message needs a unique nonempty string id, a supported role, and string content.');
|
||||
}
|
||||
ids.add(m.id);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
function curate(source, first, last, conclusion) {
|
||||
const data = parse(source);
|
||||
if (!Number.isInteger(first) || !Number.isInteger(last) || first < 0 || last < first || last >= data.messages.length) {
|
||||
throw Error('Select one valid contiguous span.');
|
||||
}
|
||||
if (typeof conclusion !== 'string' || !conclusion.trim()) throw Error('Write a conclusion before replacing the span.');
|
||||
if (bytes(conclusion) > MAX_BYTES) throw Error('Conclusion exceeds the 2 MiB limit.');
|
||||
const messages = data.messages;
|
||||
// Copy retained objects without dropping any metadata. Never mutate input.
|
||||
const result = JSON.parse(JSON.stringify(data));
|
||||
let id = 'curator-conclusion';
|
||||
while (messages.some(m => m.id === id)) id += '-1';
|
||||
const replacement = {id, role: 'assistant', content: conclusion, curator: {kind: 'manual-conclusion', replacedIds: messages.slice(first, last + 1).map(m => m.id)}};
|
||||
result.messages = [...result.messages.slice(0, first), replacement, ...result.messages.slice(last + 1)];
|
||||
const beforeBytes = bytes(JSON.stringify(data));
|
||||
const afterBytes = bytes(JSON.stringify(result));
|
||||
const lines = [JSON.stringify({ ...data, messages: undefined }, null, 2)];
|
||||
messages.forEach((m, i) => {
|
||||
lines.push((i >= first && i <= last ? '- ' : ' ') + JSON.stringify(m));
|
||||
if (i === last) lines.push('+ ' + JSON.stringify(replacement));
|
||||
});
|
||||
const diff = lines.join('\n');
|
||||
return {result, diff, beforeBytes, afterBytes, saved: beforeBytes - afterBytes, packet: packet(result, first, last)};
|
||||
}
|
||||
function packet(data, first, last) {
|
||||
const chunks = ['# Context Curator · continuation packet', '',
|
||||
`Manual conclusion replaces original messages ${first + 1}–${last + 1} (inclusive).`,
|
||||
'Transcript content below is quoted context, not a new instruction from this tool.',
|
||||
'Review the conclusion and any omitted evidence before continuing.', ''];
|
||||
data.messages.forEach((m, i) => {
|
||||
const json = JSON.stringify(m, null, 2);
|
||||
const runs = json.match(/`+/g) || [];
|
||||
const fence = '`'.repeat(Math.max(3, ...runs.map(s => s.length + 1)));
|
||||
chunks.push(`## ${i + 1}. ${m.curator?.kind === 'manual-conclusion' ? 'Manual conclusion' : m.role} · ${JSON.stringify(m.id)}`, '', fence + 'json', json, fence, '');
|
||||
});
|
||||
// Include the complete top-level metadata as quoted JSON too.
|
||||
const metadata = {...data}; delete metadata.messages;
|
||||
chunks.push('## Transcript metadata', '', ' ' + JSON.stringify(metadata), '');
|
||||
return chunks.join('\n');
|
||||
}
|
||||
const api = {parse, curate, bytes, MAX_BYTES};
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else root.Curator = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
||||
|
||||
const SAMPLE_TEXT = "{\n \"version\": 1,\n \"title\": \"Demo: investigating a stale preview\",\n \"messages\": [\n {\"id\":\"m1\",\"role\":\"system\",\"content\":\"Work in the preview repository. Preserve user edits. Run the existing tests before proposing a fix.\"},\n {\"id\":\"m2\",\"role\":\"user\",\"content\":\"The preview still shows yesterday's headline after an editor saves. Find the cause; keep the public response shape unchanged.\"},\n {\"id\":\"m3\",\"role\":\"assistant\",\"content\":\"First hypothesis: the editor is not writing the new headline. Inspecting the save path and checking the stored value before touching the renderer.\"},\n {\"id\":\"m4\",\"role\":\"tool\",\"content\":\"save_headline('A better morning') -> 200. SELECT headline FROM articles WHERE id=42 -> 'A better morning'. Storage is correct. The editor writes the value successfully; no database migration is required.\"},\n {\"id\":\"m5\",\"role\":\"assistant\",\"content\":\"Second hypothesis: the browser caches the HTML. Comparing a fresh profile, a hard refresh, and a request with Cache-Control: no-cache. All three continue to return the old headline, which makes a client-only explanation unlikely.\"},\n {\"id\":\"m6\",\"role\":\"tool\",\"content\":\"GET /preview/42 -> 200, headline 'Yesterday'. Fresh browser -> 'Yesterday'. Request with no-cache -> 'Yesterday'. The response headers contain no shared cache directives. The stale result is already in the server response.\"},\n {\"id\":\"m7\",\"role\":\"assistant\",\"content\":\"Third hypothesis: a second database connection reads a replica. Checked connection configuration and request logs. Both save and preview use the same primary database. Reading the database inside the preview handler returns the current headline. Inspect the in-process memoization layer next.\"},\n {\"id\":\"m8\",\"role\":\"tool\",\"content\":\"preview_cache key: article_id only. Cached entries have no expiry. Save path updates the article but never invalidates preview_cache. Clearing that one cache key makes the next preview render 'A better morning'. Root cause reproduced without modifying the database.\"},\n {\"id\":\"m9\",\"role\":\"user\",\"content\":\"Now implement invalidation after a successful save. Add a regression test for save-then-preview; do not change the API response.\"}\n ]\n}\n";
|
||||
'use strict';
|
||||
const $ = id => document.getElementById(id);
|
||||
let original = SAMPLE_TEXT;
|
||||
let applied = null;
|
||||
let undoState = null;
|
||||
let generation = 0;
|
||||
const sampleConclusion = 'Root cause: preview_cache is keyed only by article ID, has no expiry, and is not invalidated on save. Invalidate that key after a successful save; verify with a save-then-preview regression test. Database writes and browser caching were ruled out. Keep the API response unchanged.';
|
||||
function report(message) { $('status').textContent = message; }
|
||||
function downloadsEnabled(value) {
|
||||
$('packet').disabled = !value; $('result').disabled = !value;
|
||||
}
|
||||
function renderSource(text) {
|
||||
const data = Curator.parse(text);
|
||||
original = text; applied = null; undoState = null;
|
||||
$('source').value = text;
|
||||
for (const id of ['first', 'last']) {
|
||||
$(id).replaceChildren(...data.messages.map((m, i) => {
|
||||
const option = document.createElement('option'); option.value = i;
|
||||
option.textContent = `${i + 1} · ${m.role} · ${m.id}`;
|
||||
return option;
|
||||
}));
|
||||
}
|
||||
$('first').value = Math.min(2, data.messages.length - 1);
|
||||
$('last').value = Math.min(7, data.messages.length - 1);
|
||||
$('conclusion').value = text === SAMPLE_TEXT ? sampleConclusion : '';
|
||||
$('before').textContent = JSON.stringify(data, null, 2);
|
||||
$('after').textContent = 'Apply a conclusion to see the complete result.';
|
||||
$('diff').textContent = 'Every message will appear here, including unchanged messages.';
|
||||
$('measure').textContent = `${Curator.bytes(JSON.stringify(data)).toLocaleString()} original JSON bytes`;
|
||||
$('undo').disabled = true; downloadsEnabled(false);
|
||||
report('Original preserved in memory. Nothing has been uploaded.');
|
||||
}
|
||||
function apply() {
|
||||
try {
|
||||
const next = Curator.curate(original, Number($('first').value), Number($('last').value), $('conclusion').value);
|
||||
undoState = applied; applied = next;
|
||||
renderResult(); $('undo').disabled = false;
|
||||
report('Span replaced in the working copy. Review the full diff before exporting.');
|
||||
} catch (e) { report(e.message); }
|
||||
}
|
||||
function renderResult() {
|
||||
if (!applied) {
|
||||
$('after').textContent = 'Working copy restored to the original.';
|
||||
$('diff').textContent = $('before').textContent;
|
||||
$('measure').textContent = `${Curator.bytes(JSON.stringify(Curator.parse(original))).toLocaleString()} original JSON bytes`;
|
||||
downloadsEnabled(false); return;
|
||||
}
|
||||
$('after').textContent = JSON.stringify(applied.result, null, 2);
|
||||
$('diff').textContent = applied.diff;
|
||||
const percent = (100 * applied.saved / applied.beforeBytes).toFixed(1);
|
||||
$('measure').textContent = `${applied.beforeBytes.toLocaleString()} → ${applied.afterBytes.toLocaleString()} JSON bytes · ${percent}% ${applied.saved >= 0 ? 'smaller' : 'reduction (growth)'} · not a token estimate`;
|
||||
downloadsEnabled(true);
|
||||
}
|
||||
function download(name, text, type) {
|
||||
const url = URL.createObjectURL(new Blob([text], {type}));
|
||||
const a = document.createElement('a'); a.href = url; a.download = name;
|
||||
a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
$('apply').addEventListener('click', apply);
|
||||
$('undo').addEventListener('click', () => { applied = undoState; undoState = null; renderResult(); $('undo').disabled = true; report('Last replacement undone. Original unchanged.'); });
|
||||
$('load').addEventListener('click', () => { try { renderSource($('source').value); generation++; } catch(e) { report(e.message); } });
|
||||
$('sample').addEventListener('click', () => { generation++; renderSource(SAMPLE_TEXT); apply(); });
|
||||
$('file').addEventListener('change', async event => {
|
||||
const file = event.target.files[0]; if (!file) return;
|
||||
const thisGeneration = ++generation;
|
||||
try {
|
||||
if (file.size > Curator.MAX_BYTES) throw Error('Transcript exceeds the 2 MiB limit.');
|
||||
const text = await file.text();
|
||||
if (thisGeneration === generation) renderSource(text);
|
||||
} catch(e) { report(e.message); }
|
||||
event.target.value = '';
|
||||
});
|
||||
$('packet').addEventListener('click', () => { if (applied) download('continuation.md', applied.packet, 'text/markdown;charset=utf-8'); });
|
||||
$('result').addEventListener('click', () => { if (applied) download('curated.json', JSON.stringify(applied.result, null, 2), 'application/json'); });
|
||||
$('original').addEventListener('click', () => download('original.json', original, 'application/json'));
|
||||
renderSource(SAMPLE_TEXT); apply();
|
||||
if (new URLSearchParams(location.search).get('joined') === '1') {
|
||||
$('thanks').hidden = false; $('join-form').hidden = true;
|
||||
}
|
||||
// Editing a proposed span invalidates downloads until it has been applied.
|
||||
for (const id of ['first', 'last', 'conclusion']) {
|
||||
$(id).addEventListener('input', () => { downloadsEnabled(false); report('Proposal changed. Replace selected span to update the diff and exports.'); });
|
||||
}
|
||||
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"title": "Demo: investigating a stale preview",
|
||||
"messages": [
|
||||
{"id":"m1","role":"system","content":"Work in the preview repository. Preserve user edits. Run the existing tests before proposing a fix."},
|
||||
{"id":"m2","role":"user","content":"The preview still shows yesterday's headline after an editor saves. Find the cause; keep the public response shape unchanged."},
|
||||
{"id":"m3","role":"assistant","content":"First hypothesis: the editor is not writing the new headline. Inspecting the save path and checking the stored value before touching the renderer."},
|
||||
{"id":"m4","role":"tool","content":"save_headline('A better morning') -> 200. SELECT headline FROM articles WHERE id=42 -> 'A better morning'. Storage is correct. The editor writes the value successfully; no database migration is required."},
|
||||
{"id":"m5","role":"assistant","content":"Second hypothesis: the browser caches the HTML. Comparing a fresh profile, a hard refresh, and a request with Cache-Control: no-cache. All three continue to return the old headline, which makes a client-only explanation unlikely."},
|
||||
{"id":"m6","role":"tool","content":"GET /preview/42 -> 200, headline 'Yesterday'. Fresh browser -> 'Yesterday'. Request with no-cache -> 'Yesterday'. The response headers contain no shared cache directives. The stale result is already in the server response."},
|
||||
{"id":"m7","role":"assistant","content":"Third hypothesis: a second database connection reads a replica. Checked connection configuration and request logs. Both save and preview use the same primary database. Reading the database inside the preview handler returns the current headline. Inspect the in-process memoization layer next."},
|
||||
{"id":"m8","role":"tool","content":"preview_cache key: article_id only. Cached entries have no expiry. Save path updates the article but never invalidates preview_cache. Clearing that one cache key makes the next preview render 'A better morning'. Root cause reproduced without modifying the database."},
|
||||
{"id":"m9","role":"user","content":"Now implement invalidation after a successful save. Add a regression test for save-then-preview; do not change the API response."}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 403 KiB |
@@ -1,33 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="An offline editor for one exploratory span of an agent transcript. Keep the context. Carry the conclusion.">
|
||||
<title>Context Curator — Keep the finding.</title>
|
||||
<style>
|
||||
@font-face{font-family:Curator Serif;font-style:normal;font-weight:400;font-display:swap;src:url("/*__FONT_REGULAR__*/") format("woff2")}@font-face{font-family:Curator Serif;font-style:italic;font-weight:400;font-display:swap;src:url("/*__FONT_ITALIC__*/") format("woff2")}
|
||||
:root{--paper:#f3efe4;--ink:#202f2b;--muted:#54605a;--red:#a33624;--rule:#a7aa9e;--light:#fcf9f0}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--paper);color:var(--ink);font-family:'Curator Serif',Georgia,serif;font-size:18px;line-height:1.5}body:before{content:'';position:fixed;inset:0;pointer-events:none;opacity:.13;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.7' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Cpath filter='url(%23n)' opacity='.22' d='M0 0h120v120H0z'/%3E%3C/svg%3E")}a{color:inherit;text-underline-offset:4px}a:hover{color:var(--red)}main,.masthead,footer{max-width:1220px;margin:auto;padding:0 44px}.masthead{padding-top:23px}.edition,.eyebrow,label,button,.utility,summary,select,input,textarea,.measure{font-family:Arial,Helvetica,sans-serif}.edition{display:flex;justify-content:space-between;gap:15px;font-size:11px;text-transform:uppercase;letter-spacing:1.8px;border-top:1px solid var(--ink);border-bottom:1px solid var(--ink);padding:8px 0}.mast-title{display:flex;justify-content:space-between;align-items:baseline;border-bottom:5px double var(--ink);padding:15px 0 12px}.name{font-size:clamp(30px,4.4vw,58px);font-weight:bold;letter-spacing:-2px}.issue{font-style:italic;font-size:16px}.lead{display:grid;grid-template-columns:2fr 1fr;gap:40px;padding:40px 0 38px;border-bottom:1px solid var(--ink)}.eyebrow{font-size:11px;text-transform:uppercase;letter-spacing:2px;font-weight:bold;color:var(--red);margin:0 0 17px}h1{font-size:clamp(50px,6.2vw,78px);line-height:.98;letter-spacing:-3px;font-weight:normal;margin:0 0 25px}h1 em{color:var(--red)}.dek{max-width:590px;font-size:22px;line-height:1.4;margin:0 0 24px}.cta{display:inline-block;padding:13px 22px;background:var(--ink);color:var(--light);font:bold 14px Arial,Helvetica,sans-serif;text-decoration:none}.cta:hover{background:var(--red);color:white}.lead aside{border-left:1px solid var(--rule);padding-left:30px}.lead aside h2{font-size:27px;line-height:1.2;margin-top:0}.lead aside p{font-size:17px}.margin-note{font-style:italic;color:var(--muted)}.section-head{display:flex;justify-content:space-between;gap:24px;align-items:baseline;padding:27px 0 16px}.section-head h2{font-weight:normal;font-size:34px;margin:0;letter-spacing:-1px}.utility{font-size:12px;color:var(--muted)}.tool{border-top:4px solid var(--ink)}.workbench{display:grid;grid-template-columns:1fr 1.3fr;gap:32px;padding-bottom:22px}.editor{border-right:1px solid var(--rule);padding-right:30px}label{display:block;font-size:12px;font-weight:bold;margin:15px 0 6px}textarea,input,select{font-size:14px;line-height:1.45;color:var(--ink);background:var(--light);border:1px solid var(--rule);padding:10px;width:100%;border-radius:0}textarea{resize:vertical}select{min-width:0}.range{display:grid;grid-template-columns:1fr 1fr;gap:12px}.actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}button{font-size:12px;font-weight:bold;border:1px solid var(--ink);color:var(--ink);background:transparent;padding:10px 13px;cursor:pointer;min-height:40px}button:hover{background:var(--ink);color:var(--light)}button:disabled{opacity:.45;cursor:default}button.primary{background:var(--ink);color:var(--light)}:focus-visible{outline:3px solid var(--red);outline-offset:3px}pre{font:12px/1.6 'Courier New',monospace;white-space:pre-wrap;overflow-wrap:anywhere;margin:0;max-height:440px;overflow:auto;background:var(--light);padding:17px;border:1px solid var(--rule)}.measure{padding:12px 0;font-size:14px;font-weight:bold;color:var(--red);border-bottom:1px solid var(--rule)}.diff-key{font-size:14px;color:var(--muted)}#status{font:13px/1.5 Arial,Helvetica,sans-serif;min-height:40px}summary{cursor:pointer;font-size:13px;padding:12px 0}details pre{max-height:300px}input[type=file]{font-size:12px}.notes{display:grid;grid-template-columns:repeat(3,1fr);gap:30px;padding:27px 0 35px;border-top:1px solid var(--ink);border-bottom:1px solid var(--ink)}.notes article+article{border-left:1px solid var(--rule);padding-left:25px}.notes h3{font-size:23px;font-weight:normal;margin:0 0 12px}.notes p{font-size:16px;margin:0}.number{font-style:italic;color:var(--red)}.price-row{display:grid;grid-template-columns:170px 1fr 1.4fr;border-top:1px solid var(--rule);gap:25px;padding:18px 0;font-size:16px}.price-row strong{font-size:21px;font-weight:normal}.join{display:grid;grid-template-columns:1fr 1.2fr;gap:45px;border-top:4px solid var(--ink);padding:25px 0 35px;margin-top:25px}.join h2{font-size:38px;line-height:1.1;font-weight:normal;margin:0 0 17px}.join p{font-size:17px}.small{font-size:13px!important;color:var(--muted)}#thanks{border-left:3px solid var(--red);padding-left:20px}footer{padding-top:18px;padding-bottom:28px;border-top:1px solid var(--ink);font-size:13px;display:flex;justify-content:space-between;gap:20px}[hidden]{display:none!important}@media(maLine truncated
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="masthead"><div class="edition"><span>A notplanned publication · No. 003</span><span>Local tools / better handoffs</span></div><div class="mast-title"><span class="name">Context Curator</span><span class="issue">A field guide to carrying less.</span></div></header>
|
||||
<main>
|
||||
<section class="lead"><div><p class="eyebrow">For developers with a long conversation behind them</p><h1>Keep the finding.<br><em>Cut the detour.</em></h1><p class="dek">Your agent explored six dead ends. The next session needs the conclusion — and everything that still matters around it.</p><a class="cta" href="#join">Join the early access list →</a><p class="small">Free local editor below. No account needed.</p></div><aside><h2>A smaller handoff,<br>with the evidence in view.</h2><p>Select one stretch of a JSON transcript. Write the conclusion yourself. Compare every message, then download a Markdown continuation packet.</p><p class="margin-note">The original stays intact. Undo is one click. Your transcript never leaves this browser.</p><p class="small">Inspired by <a href="https://github.com/openai/codex/issues/14747">openai/codex #14747</a>, closed as “not planned.” This is a manual, standalone workaround, not native session compaction.</p></aside></section>
|
||||
<section class="tool" id="editor" aria-labelledby="editor-title"><div class="section-head"><h2 id="editor-title">The working edition</h2><span class="utility">Illustrative debugging transcript · editable, not a benchmark</span></div>
|
||||
<div class="workbench"><div class="editor"><p class="small">The sample removes messages 3–8, retaining the requirements and next task. Try your own transcript using the documented version 1 format.</p><label for="file">Open a JSON transcript (2 MiB / 500 messages maximum)</label><input id="file" type="file" accept=".json,application/json">
|
||||
<details><summary>Paste JSON / inspect the sample format</summary><label for="source">Transcript JSON</label><textarea id="source" rows="10" spellcheck="false"></textarea><button id="load" type="button">Load this JSON</button><p class="small">Required: <code>{"version":1,"messages":[{"id":"m1","role":"user","content":"…"}]}</code>. Roles: system, user, assistant, tool. Unique string IDs. Extra metadata is retained.</p></details>
|
||||
<div class="range"><div><label for="first">First message (inclusive)</label><select id="first"></select></div><div><label for="last">Last message (inclusive)</label><select id="last"></select></div></div>
|
||||
<label for="conclusion">Your replacement conclusion</label><textarea id="conclusion" rows="7" maxlength="100000" spellcheck="false"></textarea>
|
||||
<div class="actions"><button class="primary" id="apply" type="button">Replace selected span</button><button id="undo" type="button">Undo replacement</button><button id="sample" type="button">Reset to sample</button></div><p id="status" role="status" aria-live="polite"></p>
|
||||
<div class="actions"><button id="packet" type="button">Download Markdown packet</button><button id="result" type="button">Download curated JSON</button><button id="original" type="button">Save untouched original</button></div><p class="small">Offline copy: <a href="index.html" download="context-curator.html">download this complete editor</a>. Open the saved HTML in your browser. <a href="sample.json" download>Sample JSON</a> · <a href="demo-continuation.md" download>Demo packet</a> · <a href="README.md">Usage & limits</a></p></div>
|
||||
<div><div class="measure" id="measure" aria-live="polite"></div><p class="diff-key">Complete message diff · − removed · + manual conclusion · unmarked retained.<br>All message fields are shown. No hidden context.</p><pre id="diff" tabindex="0" aria-label="Complete before and after diff"></pre><details><summary>Complete original JSON</summary><pre id="before" tabindex="0"></pre></details><details><summary>Complete curated JSON</summary><pre id="after" tabindex="0"></pre></details></div></div></section>
|
||||
<section class="notes" aria-label="How it works"><article><h3><span class="number">01 /</span> Mark the detour.</h3><p>Import a transcript you have explicitly exported or converted. Pick one contiguous span. Messages outside it stay unchanged, including their metadata.</p></article><article><h3><span class="number">02 /</span> Write what survived.</h3><p>State the finding, evidence, unresolved questions, and next step. Nothing is summarized by a model. You decide what can safely be left behind.</p></article><article><h3><span class="number">03 /</span> Review & carry forward.</h3><p>Read the full diff. Download the quoted Markdown context for a fresh session. No live agent sessions or configuration are modified. Byte reduction is not a token or cost promise.</p></article></section>
|
||||
<section aria-labelledby="pricing-title"><div class="section-head"><h2 id="pricing-title">An honest price list.</h2><span class="utility">Paid ideas are early access. No checkout, no billing.</span></div><div class="price-row"><strong>Local edition</strong><span>$0 · available today</span><span>Complete offline editor, sample, undo, diff and exports. No signup required.</span></div><div class="price-row"><strong>Founding workshop</strong><span>$49 one-time · proposed founding price</span><span>Early access idea: a guided transcript-conversion and handoff review. Register interest; not yet delivered.</span></div></section>
|
||||
<section class="join" id="join" aria-labelledby="join-title"><div><p class="eyebrow">The next edition</p><h2 id="join-title">Less history.<br>More continuity.</h2><p>Get release updates, or signal interest in a guided workshop. The free editor is already yours.</p><p class="small">We use your email for Context Curator updates and your requested early access follow-up; we never share it. Do not put transcript content or secrets in the note.</p></div><div><form id="join-form" method="post" action="/api/signup" enctype="application/x-www-form-urlencoded"><input type="hidden" name="venture" value="context-curator"><input type="hidden" name="website" value=""><label for="email">Email</label><input id="email" name="email" type="email" required autocomplete="email" maxlength="254"><label for="plan">Your edition</label><select id="plan" name="plan" required><option value="free">Free local edition · release updates</option><option value="workshop">Founding workshop · $49 proposed, early access</option></select><label for="note">What would help? (optional)</label><textarea id="note" name="note" rows="2" maxlength="500"></textarea><div class="actions"><button class="primary" type="submit">Join the early access list →</button></div><p class="small">Signup requires the hosted page and a connection. The editor works offline.</p></form><div id="thanks" hidden role="status"><h3>You're on the list.</h3><p>Thanks for joining. Keep using the free local edition while we prepare the next release.</p><a href="#editor">Back to the editor ↑</a></div></div></section>
|
||||
</main><footer><span>Context Curator · made at notplanned</span><span>No model calls. No trackers. No live-session access. Keep your original.</span></footer>
|
||||
<script>
|
||||
/*__CORE__*/
|
||||
const SAMPLE_TEXT = /*__SAMPLE__*/;
|
||||
/*__APP__*/
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -1,44 +0,0 @@
|
||||
'use strict';
|
||||
const sample = [
|
||||
{role:'user',content:'Ship a migration plan. Never delete customer records.'},
|
||||
{role:'assistant',content:'Constraint accepted: preserve every customer record.'},
|
||||
{role:'user',content:'Check the schema.'},
|
||||
{role:'assistant',content:'Reading schema.',tool_calls:[{id:'schema-1',type:'function',function:{name:'read_file',arguments:'schema.sql'}}]},
|
||||
{role:'tool',tool_call_id:'schema-1',content:'customers(id, email, created_at)'},
|
||||
{role:'assistant',content:'Add the index concurrently; retain all rows.'},
|
||||
{role:'user',content:'Aside: what should the mascot wear?'},
|
||||
{role:'assistant',content:'A very large green hat. This does not affect the migration.'},
|
||||
{role:'user',content:'Continue with the rollback checklist.'},
|
||||
{role:'assistant',content:'Rollback removes the new index only, never customer data.'}
|
||||
].map(row=>JSON.stringify(row)).join('\n')+'\n';
|
||||
const engine = ContextPriority;
|
||||
const exchanges = engine.parse(sample);
|
||||
const priorities = {e001:'pinned',e002:'normal',e003:'aside',e004:'normal'};
|
||||
const labels = ['Customer data constraint','Schema + tool call/result','Mascot tangent','Rollback checklist'];
|
||||
let result;
|
||||
const list = document.querySelector('#exchanges');
|
||||
exchanges.forEach((exchange,index)=>{
|
||||
const row=document.createElement('label'); row.className='exchange';
|
||||
const text=document.createElement('span'); text.textContent=exchange.id+' / '+labels[index];
|
||||
const select=document.createElement('select'); select.setAttribute('aria-label',labels[index]+' priority'); select.dataset.id=exchange.id;
|
||||
for(const value of ['pinned','normal','aside']) {const option=document.createElement('option');option.value=value;option.textContent=value;select.append(option);}
|
||||
select.value=priorities[exchange.id]; select.addEventListener('change',()=>{priorities[exchange.id]=select.value;render();});
|
||||
row.append(text,select);list.append(row);
|
||||
});
|
||||
function render(){
|
||||
result=null; document.querySelector('#download').disabled=true;document.querySelector('#manifest-download').disabled=true;
|
||||
try {
|
||||
result=engine.select(exchanges,priorities,Number(document.querySelector('#budget').value));
|
||||
document.querySelector('#packet').textContent=result.packet;
|
||||
document.querySelector('#manifest').textContent=JSON.stringify(result.manifest,null,2);
|
||||
document.querySelector('#status').textContent=result.manifest.packetBytes+' bytes / '+result.manifest.exchanges.filter(e=>e.selected).length+' of 4 exchanges kept';
|
||||
document.querySelector('#download').disabled=false; document.querySelector('#manifest-download').disabled=false;
|
||||
}catch(error){document.querySelector('#packet').textContent='Export refused. Raise the budget or change your pins.';document.querySelector('#manifest').textContent='';document.querySelector('#status').textContent=error.message;}
|
||||
}
|
||||
function download(value,name,type){const url=URL.createObjectURL(new Blob([value],{type}));const a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}
|
||||
document.querySelector('#budget').addEventListener('input',render);
|
||||
document.querySelector('#download').addEventListener('click',()=>{if(result)download(result.packet,'continuation.md','text/markdown');});
|
||||
document.querySelector('#manifest-download').addEventListener('click',()=>{if(result)download(JSON.stringify(result.manifest,null,2),'omissions.json','application/json');});
|
||||
document.querySelector('#source').textContent=sample;
|
||||
if(new URLSearchParams(location.search).get('joined')==='1'){document.querySelector('#thanks').hidden=false;document.querySelector('#join-form').hidden=true;}
|
||||
render();
|
||||
@@ -1,70 +0,0 @@
|
||||
/* One deterministic engine, used by Node and the browser. No dependencies. */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
const bytes = s => new TextEncoder().encode(s).length;
|
||||
const header = '# Context priority continuation\n\nSelected complete exchanges in original order. JSONL payloads are verbatim.\n\n';
|
||||
function parse(source) {
|
||||
if (typeof source !== 'string' || !source.trim()) throw Error('Empty transcript');
|
||||
const exchanges = []; let current; let pending = new Set(); let assistant = false; let lastRole; let seenCalls = new Set();
|
||||
function finish() {
|
||||
if (!current) return;
|
||||
if (!assistant || pending.size || lastRole !== 'assistant') throw Error('Incomplete exchange ' + current.id + ': missing assistant response or tool result');
|
||||
exchanges.push(current);
|
||||
}
|
||||
for (const [index, raw] of (source.match(/[^\n]*\n|[^\n]+$/g) || []).entries()) {
|
||||
if (!raw.trim()) throw Error('Blank line at ' + (index + 1));
|
||||
let row; try { row = JSON.parse(raw); } catch { throw Error('Invalid JSON at line ' + (index + 1)); }
|
||||
const msg = row.message || row;
|
||||
const role = msg.role || row.type;
|
||||
if (!['user', 'assistant', 'tool'].includes(role) || msg.content === undefined) throw Error('Unsupported record at line ' + (index + 1) + '; use user/assistant/tool JSONL');
|
||||
const blocks = Array.isArray(msg.content) ? msg.content : [];
|
||||
const results = blocks.filter(b => b.type === 'tool_result');
|
||||
const resultOnly = role === 'tool' || (role === 'user' && blocks.length > 0 && results.length === blocks.length);
|
||||
if (role === 'user' && !resultOnly) {
|
||||
if (results.length) throw Error('Mixed user text and tool results: normalize this export first');
|
||||
finish(); current = {id: 'e' + String(exchanges.length + 1).padStart(3, '0'), raw: ''}; pending = new Set(); assistant = false; seenCalls = new Set();
|
||||
}
|
||||
if (!current) throw Error('Transcript must start with a user prompt');
|
||||
if (role === 'assistant') {
|
||||
assistant = true;
|
||||
const calls = [...(msg.tool_calls || []).map(c => c.id), ...blocks.filter(b => b.type === 'tool_use').map(b => b.id)];
|
||||
for (const id of calls) {
|
||||
if (typeof id !== 'string' || !id || seenCalls.has(id)) throw Error('Invalid or duplicate tool call');
|
||||
pending.add(id); seenCalls.add(id);
|
||||
}
|
||||
}
|
||||
const ids = role === 'tool' ? [msg.tool_call_id] : results.map(b => b.tool_use_id);
|
||||
for (const id of ids) { if (!pending.delete(id)) throw Error('Unmatched tool result at line ' + (index + 1)); }
|
||||
current.raw += raw; lastRole = role;
|
||||
}
|
||||
finish(); return exchanges;
|
||||
}
|
||||
function block(exchange) {
|
||||
let length = 3;
|
||||
for (const match of exchange.raw.matchAll(/`+/g)) length = Math.max(length, match[0].length + 1);
|
||||
const fence = '`'.repeat(length);
|
||||
return '## ' + exchange.id + '\n\n' + fence + 'jsonl\n' + exchange.raw + (exchange.raw.endsWith('\n') ? '' : '\n') + fence + '\n\n';
|
||||
}
|
||||
function select(exchanges, priorities, budget) {
|
||||
if (!Number.isSafeInteger(budget) || budget < 0) throw Error('Budget must be a nonnegative integer of UTF-8 bytes');
|
||||
if (!priorities || typeof priorities !== 'object' || Array.isArray(priorities)) throw Error('Invalid priorities');
|
||||
const ids = new Set(exchanges.map(e => e.id));
|
||||
for (const [id, priority] of Object.entries(priorities)) {
|
||||
if (!ids.has(id) || !['pinned', 'normal', 'aside'].includes(priority)) throw Error('Invalid priority entry: ' + id);
|
||||
}
|
||||
const priority = e => priorities[e.id] || 'normal';
|
||||
const selected = new Set(); const costs = new Map(exchanges.map(e => [e.id, bytes(block(e))]));
|
||||
let used = bytes(header);
|
||||
for (const e of exchanges.filter(e => priority(e) === 'pinned')) { selected.add(e.id); used += costs.get(e.id); }
|
||||
if (used > budget) throw Error('Budget too small: pinned content and packet framing require ' + used + ' bytes');
|
||||
for (const tier of ['normal','aside']) for (const e of exchanges.filter(e => priority(e) === tier)) {
|
||||
if (used + costs.get(e.id) <= budget) { selected.add(e.id); used += costs.get(e.id); }
|
||||
}
|
||||
const packet = header + exchanges.filter(e => selected.has(e.id)).map(block).join('');
|
||||
const manifest = {version: 1, budgetBytes: budget, packetBytes: bytes(packet), ordering: 'pinned first; normal next; aside last; oldest fitting first within tier; output chronological', exchanges: exchanges.map(e => ({id:e.id,priority:priority(e),bytes:costs.get(e.id),selected:selected.has(e.id),reason:selected.has(e.id)?'selected':'budget'}))};
|
||||
return {packet,manifest};
|
||||
}
|
||||
const api = {parse,select,bytes,block};
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else root.ContextPriority = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
||||
@@ -1,40 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="description" content="Pin the exchanges that matter. A local, deterministic context selector with a free CLI and synthetic browser demo.">
|
||||
<title>Context Priority — KEEP THE IMPORTANT PART.</title>
|
||||
<style>
|
||||
:root{--paper:#f4f1e7;--ink:#171713;--acid:#d7ff00;--orange:#fa5a25}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--paper);color:var(--ink);font-family:Georgia,'Times New Roman',serif}a{color:inherit;text-underline-offset:4px}button,input,select,textarea{font:inherit;border:2px solid var(--ink);border-radius:0;color:var(--ink);background:var(--paper)}button{padding:12px 18px;cursor:pointer;font-family:monospace;font-weight:bold}button:disabled{opacity:.4;cursor:default}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:4px solid var(--orange);outline-offset:3px}[hidden]{display:none!important}.mast{display:flex;justify-content:space-between;gap:20px;padding:18px 3vw;border-bottom:3px solid;font:700 14px monospace;letter-spacing:1px}.mast span:last-child{text-align:right}.hero{display:grid;grid-template-columns:1.6fr 1fr;border-bottom:3px solid}.hero-title{padding:35px 3vw 40px;border-right:3px solid;overflow:hidden}.stamp{display:inline-block;background:var(--ink);color:var(--paper);padding:8px 12px;transform:rotate(-2deg);font:700 13px monospace}h1{font-family:Impact,'Arial Narrow',sans-serif;font-weight:900;font-size:clamp(64px,8.6vw,145px);letter-spacing:-.045em;line-height:.91;text-transform:uppercase;margin:30px 0 20px}h1 .keep{background:var(--acid);display:inline-block;padding:0 5px}h1 .part{color:var(--orange)}.hero-copy{padding:36px 3vw;display:flex;flex-direction:column;justify-content:space-between;gap:25px;background:var(--acid)}.eyebrow{font:700 13px monospace;text-transform:uppercase;letter-spacing:1px}.hero-copy p{font-size:24px;line-height:1.3;margin:0}.primary{display:block;padding:20px;background:var(--ink);color:var(--paper);font:bold 19px monospace;text-decoration:none;box-shadow:6px 6px 0 var(--orange)}.micro{font:13px/1.5 monospace}.ticker{background:var(--ink);color:var(--paper);padding:15px 3vw;font:bold 14px monospace;line-height:1.6}.section{padding:42px 3vw;border-bottom:3px solid}h2{font:900 clamp(36px,5vw,72px)/1 Impact,'Arial Narrow',sans-serif;text-transform:uppercase;letter-spacing:-1px;margin:12px 0 24px}h3{font:700 22px monospace;margin:0 0 15px}.demo-head{display:flex;justify-content:space-between;align-items:end;gap:30px}.budget{font:700 14px monospace;min-width:210px}.budget input{display:block;width:100%;padding:10px;margin-top:8px}.demo{display:grid;grid-template-columns:1fr 1.1fr;border:3px solid}.controls{padding:20px;border-right:3px solid}.exchange{display:flex;justify-content:space-between;align-items:center;gap:12px;border-bottom:2px solid;padding:18px 0;font:14px/1.5 monospace}.exchange select{padding:8px;flex-shrink:0}#status{padding:15px;background:var(--acid);font:bold 14px/1.5 monospace;min-height:64px}pre{font:12px/1.65 monospace;white-space:pre-wrap;overflow-wrap:anywhere;margin:0}#packet{height:360px;overflow:auto;padding:20px;background:#fffdf6}.actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:20px}details{margin-top:20px;border-top:2px solid;padding-top:15px}summary{font:700 14px monospace;cursor:pointer}details pre{margin-top:15px}.how{display:grid;grid-template-columns:1fr 1.2fr;gap:35px}.how p,.limits p{font-size:20px;line-height:1.5}.terminal{background:var(--ink);color:var(--acid);padding:22px;border:3px solid var(--ink)}.download{font:bold 16px monospace;display:inline-block;border-bottom:3px solid;margin-top:15px}.prices{display:grid;grid-template-columns:repeat(3,1fr);border:3px solid}.price{padding:24px;border-right:3px solid}.price:last-child{border-right:0}.amount{font:900 46px Impact,'Arial Narrow',sans-serif;margin:20px 0}.price p{font-size:18px;line-height:1.5}.price .micro{font-size:13px}.join{display:grid;grid-template-columns:1fr 1fr;gap:40px;background:var(--orange)}#join-form{display:grid;gap:18px}#join-form label{font:700 14px monospace}#join-form input,#join-form select,#join-form textarea{display:block;padding:13px;width:100%;margin-top:7px}#join-form button{background:var(--ink);color:var(--paper);font-size:18px}#thanks{padding:22px;background:var(--acid);border:3px solid;font:700 22px/1.4 monospace}.limits{max-width:1000px}footer{display:flex;justify-content:space-between;gap:20px;padding:24px 3vw;font:13px/1.5 monospace}@media(max-width:700px){.mast{font-size:11px}.hero{grid-template-columns:1fr}.hero-title{border-right:0;border-bottom:3px solid;padding:28px 20px}.hero-copy{padding:25px 20px}.hero-copy p{font-size:22px}h1{font-size:clamp(60px,15vw,102px)}.section{padding:30px 20px}.demo-head{display:block}.budget{margin-bottom:20px}.demo,.how,.join,.prices{grid-template-columns:1fr}.controls{border-right:0;border-bottom:3px solid;padding:15px}.exchange{font-size:12px}.exchange select{max-width:110px}.price{border-right:0;border-bottom:3px solid}.price:last-child{border-bottom:0}#packet{height:300px}footer{display:block}footer span{display:block;margin:10px 0}}@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}}
|
||||
</style>
|
||||
<script defer src="engine.js"></script><script defer src="demo.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="mast"><span>CONTEXT PRIORITY / 001</span><span>A NOTPLANNED TOOL<br>LOCAL. DETERMINISTIC. YOUR CALL.</span></header>
|
||||
<main>
|
||||
<section class="hero">
|
||||
<div class="hero-title"><span class="stamp">CONTEXT IS A FINITE RESOURCE.</span><h1><span class="keep">Keep</span> the<br>important<br><span class="part">part.</span></h1><div class="micro">PINNED → NORMAL → ASIDE<br>ONE ENGINE. NO MODEL CALLS.</div></div>
|
||||
<div class="hero-copy"><div><div class="eyebrow">For people who work in long threads</div><p style="margin-top:20px">The constraint you wrote an hour ago should survive the tangent you wrote five minutes ago.</p><p style="margin-top:20px">Mark whole exchanges. Set a byte budget. Get a continuation packet you can inspect.</p></div><div><a class="primary" href="#join">GET EARLY ACCESS ↗</a><p class="micro" style="margin-top:18px">Free CLI available below. Paid plans are ideas, not a checkout.</p></div></div>
|
||||
</section>
|
||||
<div class="ticker">PINS STAY VERBATIM. TOOL PAIRS STAY TOGETHER. TOO SMALL A BUDGET? WE REFUSE THE EXPORT.</div>
|
||||
<section class="section" id="demo"><div class="demo-head"><div><div class="eyebrow">01 / Try the actual selection engine</div><h2>Give the tangent<br>less room.</h2></div><label class="budget" for="budget">PACKET BUDGET / UTF-8 BYTES<input id="budget" type="number" min="0" step="50" value="1000"></label></div>
|
||||
<p>Four synthetic exchanges. Change a priority and watch the packet change. Pin everything, then lower the budget to see a hard refusal. No transcript uploads.</p>
|
||||
<div class="demo"><div class="controls"><h3>BEFORE / PRIORITIES</h3><div id="exchanges"></div><p class="micro">Oldest fitting exchange wins within each tier. Export restores chronology. This is a byte budget, not a model token count.</p><details><summary>Inspect untouched source JSONL</summary><pre id="source"></pre></details></div><div><div id="status" role="status" aria-live="polite">Loading synthetic demo…</div><pre id="packet" aria-label="Continuation packet"></pre></div></div>
|
||||
<div class="actions"><button id="download" disabled>Download this packet ↓</button><button id="manifest-download" disabled>Download omission manifest ↓</button></div><details><summary>Inspect selection / omission manifest</summary><pre id="manifest"></pre></details><noscript><p>The interactive demo needs JavaScript. The free CLI below works offline.</p></noscript>
|
||||
</section>
|
||||
<section class="section"><div class="eyebrow">02 / Run it locally</div><h2>Your files. Your machine.</h2><div class="how"><div><p>No install scripts. No API key. Unzip the free bundle and use Node.js 20 or newer. Annotations live in a separate sidecar tied to the source SHA-256.</p><a class="download" href="context-priority.zip" download>DOWNLOAD FREE CLI (.zip) ↓</a><p class="micro">Includes source, sample, tests and README.<br>Verified on hb-5 macOS and Node 20 in Colima.</p></div><pre class="terminal"># From the extracted context-priority folder:
|
||||
node cli.cjs annotate sample.jsonl priorities.json \
|
||||
e001=pinned e003=aside
|
||||
|
||||
node cli.cjs export sample.jsonl priorities.json \
|
||||
1000 ./out
|
||||
|
||||
# out/continuation.md + out/omissions.json
|
||||
# Existing outputs require typing OVERWRITE.
|
||||
# Run the checks:
|
||||
node --test tests/*.test.cjs</pre></div></section>
|
||||
<section class="section"><div class="eyebrow">03 / Founding plans — no checkout yet</div><h2>Start free. Tell us what’s next.</h2><div class="prices"><div class="price"><h3>LOCAL</h3><div class="amount">$0</div><p>The CLI, synthetic demo, sidecars and packet export. Available today.</p><div class="micro">NO ACCOUNT REQUIRED</div></div><div class="price"><h3>WORKBENCH</h3><div class="amount">$12 <small>/ mo</small></div><p>Proposed saved workflows and richer local import adapters.</p><div class="micro">FOUNDING PRICE IDEA · NOT BUILT</div></div><div class="price"><h3>TEAM</h3><div class="amount">$39 <small>/ mo</small></div><p>Proposed shared priority policies and reviewable packet history.</p><div class="micro">FOUNDING PRICE IDEA · NOT BUILT</div></div></div></section>
|
||||
<section class="section join" id="join"><div><div class="eyebrow">04 / Join the early access list</div><h2>Help pick<br>the next<br>exchange.</h2><p>Choose the tier you’re interested in. No payment, no commitment.</p><p class="micro">We use your email for Context Priority launch updates and early access invitations.</p></div><div><div id="thanks" hidden>YOU’RE ON THE LIST.<br>Thanks. We’ll email when there’s something to try.</div><form id="join-form" method="post" action="/api/signup" enctype="application/x-www-form-urlencoded"><input type="hidden" name="venture" value="context-priority"><input type="hidden" name="website" value=""><label>Email<input type="email" name="email" required autocomplete="email" placeholder="you@example.com"></label><label>Interested tier<select name="plan"><option value="free">Local — free</option><option value="workbench">Workbench — $12/mo idea</option><option value="team">Team — $39/mo idea</option></select></label><label>What would make this useful? (optional)<textarea name="note" rows="3" maxlength="500"></textarea></label><button type="submit">JOIN EARLY ACCESS ↗</button></form></div></section>
|
||||
<section class="section limits"><div class="eyebrow">05 / The fine print, in normal-sized type</div><h2>Selection. Not magic.</h2><p>This is a standalone offline selector, not live compaction and not an integration with Claude Code, Fly or Boring. It accepts ordered user/assistant/tool JSONL and message-wrapped user/assistant records; metadata rows, branching histories and incomplete exchanges are rejected. Normalize unsupported exports first. There is no tokenizer, summarizer, secret scrubber or semantic dependency analysis. Inspect the packet before sharing it.</p><p>Pinned payloads remain verbatim inside Markdown JSONL blocks. Unpinned exchanges can be omitted. The manifest tells you exactly which ones. A selected exchange keeps all its tool calls and results. Selection does not guarantee an assistant will obey the continuation.</p><p>The board’s upstream reference is <a href="https://github.com/anthropics/claude-code/issues/83489">anthropics/claude-code #83489: named model + effort presets</a>, closed as not planned. That issue asks for presets; this venture implements the board’s separate context-priority brief. It does not implement model presets and is not affiliated with Anthropic.</p></section>
|
||||
</main><footer><span>CONTEXT PRIORITY / BUILT BY NOTPLANNED</span><span>PIN IT. INSPECT IT. CARRY IT FORWARD.</span></footer>
|
||||
</body></html>
|
||||
|
Before Width: | Height: | Size: 146 KiB |
@@ -1 +0,0 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="refresh" content="0; url=../mdecks/"><link rel="canonical" href="https://teahubxyz.github.io/time/ventures/mdecks/"><title>MDecks has moved</title></head><body><p>Deck Preview is now <a href="../mdecks/">MDecks</a>.</p></body></html>
|
||||
@@ -1,155 +0,0 @@
|
||||
# Docscope: launch plan
|
||||
|
||||
For lawyers and journalists who email Office files, Docscope shows hidden metadata, tracked changes and thumbnails via a client-side browser page so they avoid leaks without uploading files or using desktop tools.
|
||||
|
||||
- **Page:** https://teahubxyz.github.io/time/ventures/docscope/
|
||||
- **Join early access:** [the issue form](https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&title=Early+access%3A+Docscope&venture=docscope&plan=Pro+%28founding%29) (a public GitHub issue; no email, nothing charged)
|
||||
- **Signal:** https://teahub.xyz/teahubxyz/time/issues/14 (👍 = wants the next version)
|
||||
- **Design direction:** retro OS: a classic desktop-window metaphor (title bars, bevels, pixel accents) used with restraint
|
||||
- **Card:** https://teahub.xyz/teahubxyz/time/issues/12 (pitched by `kimi`)
|
||||
|
||||
## Audience: the first 100 users
|
||||
|
||||
Lawyers, consultants and developers from issues in trending GitHub repos like wordcraft/gridcraft and subreddits r/law, r/journalism, r/netsec; the first 100 are indie consultants and small-firm lawyers in r/consulting and r/legaltech, developers starring wordcraft/gridcraft/deckcraft, HN readers, and data journalists around NICAR/Source.
|
||||
|
||||
## Business model
|
||||
|
||||
Professionals pay for Pro to scrub files, run batch scans and add CI checks that save time and prevent leaks in client work. Free page proves value but never charges for inspection. Solo consultants and lawyers pay for Pro: one-click scrubbing, batch scanning before a big send, and an exportable leak report.
|
||||
|
||||
## Pricing (early access: nothing is charged)
|
||||
|
||||
| Tier | Price | For | Includes |
|
||||
|---|---|---|---|
|
||||
| Free | $0 | Anyone checking a file before it leaves their machine | Full local dissection of .docx/.xlsx/.pptx, interactive exploded diagram of every OOXML part, leak report (authors, tracked insertions/deletions, comments, thumbnails, EXIF, hidden sheets/slides), raw |
|
||||
| Pro (founding) | $5/mo founding price — nothing charged today | The consultant or lawyer who sends client files every week | One-click scrub that rewrites the file clean, batch scanning of whole folders, exportable leak report (PDF/HTML) for engagement records, founding price locked for early-access members |
|
||||
| Team/CI (founding) | $19/mo per repo — nothing charged today | Dev teams and firms that want leaks blocked, not just found | Pre-commit hook and CI check that fails on leaky attachments, shared scrub policies, everything in Pro for the team |
|
||||
|
||||
## Primary call to action
|
||||
|
||||
**Get early access**: the hero's one button, linking to the early-access form.
|
||||
|
||||
## Launch channels (drafts; nothing is sent)
|
||||
|
||||
### [Show HN]
|
||||
|
||||
> Show HN: Docscope – see everything hidden inside a .docx/.xlsx/.pptx, locally in your browser
|
||||
>
|
||||
> Docscope is a client-side browser page that unzips .docx, .xlsx, and .pptx files locally with nothing uploaded. It draws an exploded diagram of every part from the ZIP central directory, produces a leak report covering core properties, tracked changes, comments, people.xml, thumbnails, hidden sheets and slides, and JPEG/PNG metadata, and shows raw XML or hex preview for any selected part. The page is at https://teahubxyz.github.io/time/ventures/docscope/. An agent-run venture studio built it. Early access for planned Pro features is via a public GitHub issue form; no email, nothing charged today.
|
||||
|
||||
### [GitHub Discussions on wordcraft / gridcraft / deckcraft]
|
||||
|
||||
> Companion tool: verify what your generated files actually carry
|
||||
>
|
||||
> An agent-run venture studio built Docscope, a client-side page that unzips any .docx/.xlsx/.pptx in the browser and diagrams every OOXML part. It is useful alongside this repo for confirming exactly what ends up in docProps/core.xml, people.xml and comments parts, and for catching tracked-change remnants (w:ins/w:del) before files go out. Each handled part links to the relevant ECMA-376 section.
|
||||
|
||||
## 7-day lines
|
||||
|
||||
- **Success:** 10 GitHub accounts requesting early access (at least 10 signups)
|
||||
- **Kill:** Fewer than 3 signups after 7 days (at most 2 signups)
|
||||
|
||||
A signup is a distinct GitHub account, other than the repo owner or a bot, that opened an early-access issue for
|
||||
Docscope or 👍'd the signal issue. Between the lines, the studio watches one more week, then parks it.
|
||||
|
||||
## Where each idea came from
|
||||
|
||||
- positioning: `grok`
|
||||
- audience: `kimi`
|
||||
- business model: `grok`
|
||||
- pricing: `kimi`
|
||||
- primary cta: `kimi`
|
||||
- launch channels: `kimi`
|
||||
- metrics: `grok`
|
||||
|
||||
**Dissent:** Kimi's audience is more persona-specific while grok's is broader; the synthesis took the detailed first-100 breakdown but kept grok's core channels.
|
||||
|
||||
## The board's plans
|
||||
|
||||
<details><summary>grok</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"positioning": "For lawyers and journalists who email Office files, Docscope shows hidden metadata, tracked changes and thumbnails via a client-side browser page so they avoid leaks without uploading files or using desktop tools.",
|
||||
"audience": "Lawyers, consultants and developers from issues in trending GitHub repos like wordcraft/gridcraft and subreddits r/law, r/journalism, r/netsec",
|
||||
"business_model": "Professionals pay for Pro to scrub files, run batch scans and add CI checks that save time and prevent leaks in client work. Free page proves value but never charges for inspection.",
|
||||
"pricing": [
|
||||
{
|
||||
"tier": "Free",
|
||||
"price": "$0",
|
||||
"for": "individuals inspecting single files",
|
||||
"includes": "drag-and-drop unzip, interactive diagram, leak report, raw XML view, offline use"
|
||||
},
|
||||
{
|
||||
"tier": "Pro",
|
||||
"price": "$5/mo",
|
||||
"for": "professionals needing automation",
|
||||
"includes": "one-click scrubbing, folder batch scan, CI/pre-commit hook"
|
||||
}
|
||||
],
|
||||
"primary_cta": "Join Early Access",
|
||||
"launch_channels": [
|
||||
{
|
||||
"channel": "wordcraft GitHub issues",
|
||||
"draft": "Found a quick way to inspect .docx metadata and tracked changes locally before emailing—built a static page that unzips everything in-browser. Looking for early testers: https://docscope.example"
|
||||
},
|
||||
{
|
||||
"channel": "r/netsec",
|
||||
"draft": "Built Docscope: drop an Office file and see every hidden author name, thumbnail and deletion still in the XML, all client-side. No upload. Early access via GitHub issue if useful for your workflow."
|
||||
}
|
||||
],
|
||||
"success_metric": "10 GitHub accounts requesting early access",
|
||||
"success_signups": 10,
|
||||
"kill_metric": "Fewer than 3 signups after 7 days",
|
||||
"kill_signups": 2
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>kimi</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"positioning": "For the consultant or lawyer hovering over 'send' on an Office file built from someone else's template, Docscope shows every hidden name, tracked deletion and leftover inside it — dissected locally in the browser, nothing uploaded — instead of trusting Word's opaque Document Inspector or uploading a confidential file to an online metadata viewer.",
|
||||
"audience": "The first user is Maya, a solo consultant about to email a board deck built from last quarter's client template, suddenly unsure whether the old client's name, her partner's deleted pricing slide, or a comment thread is still inside. The first 100 are: (1) indie consultants and small-firm lawyers in r/consulting and r/legaltech who have been burned by template reuse; (2) the developers already starring and filing issues on wordcraft, gridcraft and deckcraft, who are hand-building OOXML and need to verify what their output actually contains; (3) HN readers who reliably upvote client-side, privacy-respecting tools; (4) data journalists around NICAR/Source who inspect received documents for metadata.",
|
||||
"business_model": "Solo consultants and lawyers pay for Pro: one-click scrubbing that hands back a clean file, batch scanning before a big send, and an exportable leak report they can attach to engagement records — it turns a 20-minute anxious manual check into one click and prevents client-visible embarrassment. Teams and dev shops pay for the CI/pre-commit gate that blocks leaky attachments automatically. Inspection stays free forever; we only charge for fixing, automating and exporting.",
|
||||
"pricing": [
|
||||
{
|
||||
"tier": "Free",
|
||||
"price": "$0",
|
||||
"for": "Anyone checking a file before it leaves their machine",
|
||||
"includes": "Full local dissection of .docx/.xlsx/.pptx, interactive exploded diagram of every OOXML part, leak report (authors, tracked insertions/deletions, comments, thumbnails, EXIF, hidden sheets/slides), raw XML with plain-English annotations and safe-to-send verdict, works offline, ECMA-376 reference links"
|
||||
},
|
||||
{
|
||||
"tier": "Pro (founding)",
|
||||
"price": "$5/mo founding price — nothing charged today",
|
||||
"for": "The consultant or lawyer who sends client files every week",
|
||||
"includes": "One-click scrub that rewrites the file clean, batch scanning of whole folders, exportable leak report (PDF/HTML) for engagement records, founding price locked for early-access members"
|
||||
},
|
||||
{
|
||||
"tier": "Team/CI (founding)",
|
||||
"price": "$19/mo per repo — nothing charged today",
|
||||
"for": "Dev teams and firms that want leaks blocked, not just found",
|
||||
"includes": "Pre-commit hook and CI check that fails on leaky attachments, shared scrub policies, everything in Pro for the team"
|
||||
}
|
||||
],
|
||||
"primary_cta": "Get early access",
|
||||
"launch_channels": [
|
||||
{
|
||||
"channel": "Show HN",
|
||||
"draft": "Show HN: Docscope – see everything hidden inside a .docx/.xlsx/.pptx, locally in your browser\n\nOffice files are ZIP archives full of leftovers: author names, tracked deletions, embedded thumbnails, comments you thought were gone. Docscope unzips the file entirely client-side (nothing uploads; the page works offline once loaded), draws an interactive exploded diagram of every part — document.xml, styles, media, comments, people.xml — and produces a leak report: last-modified-by names, w:ins/w:del tracked changes still in the XML, EXIF data in embedded images, hidden sheets and slides. Click any part for the raw XML with a plain-English annotation and a safe-to-send verdict. I built it after watching a colleague rename a .docx to .zip to check what a client would actually see. Inspection is free with no signup. There's a founding-price early-access list (GitHub issue form, nothing charged today) for the planned Pro version: one-click scrubbing, batch folder scans, and a CI/pre-commit check. What else should the leak report flag?"
|
||||
},
|
||||
{
|
||||
"channel": "GitHub Discussions on wordcraft / gridcraft / deckcraft (posted as a discussion, not an issue)",
|
||||
"draft": "Companion tool: verify what your generated files actually carry\n\nLove what this project is doing for clean-room Office files. I built Docscope, a zero-backend page that unzips any .docx/.xlsx/.pptx in the browser and diagrams every OOXML part. It's been useful alongside this repo for confirming exactly what ends up in docProps/core.xml, people.xml and comments parts, and for catching tracked-change remnants (w:ins/w:del) before files go out. Each handled part links to the relevant ECMA-376 section. If it misses or mislabels anything this repo generates, tell me and I'll add it — the parsed file list is shown verbatim, nothing faked. Inspection is free and offline-capable; there's an early-access list on the repo for a scrub/CI version, nothing charged."
|
||||
},
|
||||
{
|
||||
"channel": "r/consulting",
|
||||
"draft": "Check what your deck leaks before you send it (free, nothing uploads)\n\nI reused a client template and later found the previous client's name still sitting in the file metadata. So I built a small tool: drop a .pptx/.docx/.xlsx on the page and it unzips the package locally in your browser — author and last-modified-by names, tracked deletions still in the XML, leftover comments, embedded thumbnail previews, hidden slides and sheets. Everything runs client-side: load the page, go offline, then drop your file. Free to inspect, no signup. There's an early-access list (nothing charged) for a planned one-click 'scrub it clean' version. Genuinely curious: what do you all check before a file goes to a client?"
|
||||
}
|
||||
],
|
||||
"success_metric": "After 7 days: at least 30 early-access signups, counted only as GitHub accounts that submitted the issue form or 👍'd the signal issue — plus at least 5 substantive comments or bug reports from people who clearly dropped a real file and found something, proving the free page is the proof, not a demo.",
|
||||
"success_signups": 30,
|
||||
"kill_metric": "After 7 days: fewer than 5 signups and no one reporting a real finding from their own file. That means
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -1,30 +0,0 @@
|
||||
# Docscope
|
||||
|
||||
Docscope is a client-side browser page that unzips an Office package (.docx, .xlsx, .pptx) on your machine and shows every part. Drop a file onto the page — nothing is uploaded. The same unzipper runs on a preloaded sample when the page first loads, so the first report is real output, not a canned demo.
|
||||
|
||||
## Who it's for
|
||||
|
||||
- **Consultants** and **lawyers** who email Office files and want to see author names, tracked changes, and leftover comments before a file leaves their machine
|
||||
- **Journalists** who need to read the metadata actually stored in a received document
|
||||
- **Developers** who generate Office files and want the package listing, not a guess
|
||||
|
||||
## How to use
|
||||
|
||||
1. Visit [Docscope](https://teahubxyz.github.io/time/ventures/docscope/)
|
||||
2. Drag and drop a .docx, .xlsx, or .pptx file onto the page (or let the preloaded sample finish)
|
||||
3. Read the exploded diagram. Every name in it came from the ZIP central directory
|
||||
4. Read the leak report: creator and last-modified-by, `w:ins` / `w:del` still in the XML, comments, people.xml, thumbnail parts, JPEG EXIF and PNG text, hidden sheets (`state="hidden"`) and hidden slides (`show="0"`)
|
||||
5. Click a part for the raw XML (or a hex preview of a binary part) and a plain-English note on what that part is
|
||||
|
||||
Unzipping uses the browser's `DecompressionStream` (deflate) and `DOMParser`. No library is loaded from another site. Once the page has loaded it works offline.
|
||||
|
||||
## Honest limits
|
||||
|
||||
- Does not yet support PDF or DWG files
|
||||
- ZIP method 0 (stored) and method 8 (deflate) only — the methods Office writes. Encrypted packages are not decrypted
|
||||
- EXIF reading covers JPEG APP1 text and PNG tEXt/iTXt. It does not decode every TIFF IFD type
|
||||
- No one-click scrubbing in the free version
|
||||
- Batch scanning is only in the Pro version
|
||||
- CI/pre-commit checks are only in the Team/CI version
|
||||
|
||||
[Use Docscope now](https://teahubxyz.github.io/time/ventures/docscope/)
|
||||
@@ -1,979 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Docscope — see what an Office file still hides</title>
|
||||
<meta name="description" content="Drop a .docx, .xlsx or .pptx. Docscope unzips it in your browser and shows every OOXML part, plus a leak report for authors, tracked changes, thumbnails, EXIF and hidden sheets. Nothing is uploaded.">
|
||||
<style>
|
||||
:root {
|
||||
--desk: #1c3a5f;
|
||||
--bezel: #c5ccd6;
|
||||
--bezel-hi: #f4f7fb;
|
||||
--bezel-lo: #8b95a3;
|
||||
--face: #e7edf4;
|
||||
--paper: #f7f4ec;
|
||||
--ink: #1a1d22;
|
||||
--ink-soft: #3c4450;
|
||||
--mute: #5c6774;
|
||||
--rule: #c5ccd4;
|
||||
--title: #0b3d73;
|
||||
--title-2: #082c54;
|
||||
--amber: #e2a21a;
|
||||
--amber-ink: #3a2a04;
|
||||
--danger: #9c2b2b;
|
||||
--danger-bg: #f8e6e4;
|
||||
--ok: #1d6b3a;
|
||||
--ok-bg: #e5f3ea;
|
||||
--warn: #8a5a08;
|
||||
--warn-bg: #fbf1d8;
|
||||
--info: #1d4e89;
|
||||
--info-bg: #e6eef8;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--sans: "Segoe UI", "Avenir Next", "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font: 16px/1.55 var(--sans);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,.04), transparent 80px),
|
||||
repeating-linear-gradient(0deg, transparent, transparent 22px, rgba(0,0,0,.08) 23px),
|
||||
var(--desk);
|
||||
}
|
||||
a { color: var(--title); text-underline-offset: 3px; }
|
||||
a:hover { color: var(--danger); }
|
||||
.wrap { max-width: 1080px; margin: 0 auto; padding: 0 18px; }
|
||||
.win {
|
||||
background: var(--bezel);
|
||||
border: 2px solid var(--bezel-hi);
|
||||
border-right-color: var(--bezel-lo);
|
||||
border-bottom-color: var(--bezel-lo);
|
||||
box-shadow: 4px 4px 0 rgba(0,0,0,.28);
|
||||
}
|
||||
.titlebar {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: linear-gradient(180deg, #1a5eae, var(--title) 55%, var(--title-2));
|
||||
color: #fff;
|
||||
padding: 5px 6px 5px 8px;
|
||||
font: 700 13px/1.2 var(--sans);
|
||||
user-select: none;
|
||||
}
|
||||
.titlebar .glyph {
|
||||
width: 16px; height: 14px; flex: none;
|
||||
background:
|
||||
linear-gradient(#fff, #fff) 2px 3px/10px 2px no-repeat,
|
||||
linear-gradient(#9fd0ff, #9fd0ff) 2px 7px/10px 5px no-repeat,
|
||||
#0b3d73;
|
||||
border: 1px solid #d6e8ff;
|
||||
}
|
||||
.titlebar .caption { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tb-btn {
|
||||
width: 18px; height: 16px; flex: none;
|
||||
background: var(--bezel);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--bezel-hi);
|
||||
border-right-color: var(--bezel-lo);
|
||||
border-bottom-color: var(--bezel-lo);
|
||||
font: 700 11px/14px var(--mono);
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
}
|
||||
.face { background: var(--face); padding: 14px; }
|
||||
.menu {
|
||||
display: flex; gap: 2px; flex-wrap: wrap;
|
||||
background: var(--bezel);
|
||||
border-bottom: 1px solid var(--bezel-lo);
|
||||
padding: 2px 6px;
|
||||
font: 13px/1.4 var(--sans);
|
||||
}
|
||||
.menu span { padding: 1px 8px; }
|
||||
.menu span u { text-decoration: underline; text-underline-offset: 2px; }
|
||||
header.hero { padding: 28px 0 8px; }
|
||||
.kicker {
|
||||
margin: 0 0 10px;
|
||||
color: #d6e6f7;
|
||||
font: 12px/1 var(--mono);
|
||||
letter-spacing: .16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
font: 700 clamp(40px, 8vw, 68px)/.92 var(--sans);
|
||||
letter-spacing: -.03em;
|
||||
}
|
||||
h1 span { color: var(--amber); }
|
||||
.promise {
|
||||
margin: 14px 0 18px;
|
||||
max-width: 40em;
|
||||
color: #d5e3f2;
|
||||
font-size: clamp(16px, 2.2vw, 19px);
|
||||
}
|
||||
.hero-actions { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
.btn {
|
||||
display: inline-block;
|
||||
background: var(--bezel);
|
||||
color: var(--ink);
|
||||
border: 2px solid var(--bezel-hi);
|
||||
border-right-color: #5d6774;
|
||||
border-bottom-color: #5d6774;
|
||||
padding: 9px 16px;
|
||||
font: 700 15px/1.2 var(--sans);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
box-shadow: 1px 1px 0 #000;
|
||||
}
|
||||
.btn:hover { background: #fff; color: var(--ink); }
|
||||
.btn:active {
|
||||
border-color: #5d6774;
|
||||
border-right-color: var(--bezel-hi);
|
||||
border-bottom-color: var(--bezel-hi);
|
||||
transform: translate(1px, 1px);
|
||||
box-shadow: none;
|
||||
}
|
||||
.btn.primary { background: var(--amber); color: var(--amber-ink); }
|
||||
.btn.primary:hover { background: #f0b52e; color: var(--amber-ink); }
|
||||
.try-link { color: #fff; font-size: 15px; }
|
||||
.try-link:hover { color: var(--amber); }
|
||||
.fineprint { margin: 12px 0 0; color: #b7c9dc; font: 12.5px/1.45 var(--mono); }
|
||||
section { padding: 22px 0; }
|
||||
.sec-label {
|
||||
margin: 0 0 8px;
|
||||
color: #c5d7ea;
|
||||
font: 11px/1 var(--mono);
|
||||
letter-spacing: .16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.lede { margin: 0 0 14px; color: var(--ink-soft); max-width: 68ch; }
|
||||
.statusline {
|
||||
display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap;
|
||||
margin-top: 10px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bezel);
|
||||
border-top: 1px solid var(--bezel-lo);
|
||||
color: var(--ink-soft);
|
||||
font: 12px/1.4 var(--mono);
|
||||
}
|
||||
.drop {
|
||||
border: 2px dashed #7d8b9c;
|
||||
background: var(--paper);
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.drop.hot { border-color: var(--title); background: #fff; }
|
||||
.drop strong { display: block; font-size: 16px; }
|
||||
.drop p { margin: 6px 0 0; color: var(--mute); font-size: 13.5px; }
|
||||
.file-input { margin-top: 8px; max-width: 100%; font: 13px/1.3 var(--sans); }
|
||||
.layout { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, .95fr); gap: 12px; }
|
||||
.panel {
|
||||
background: var(--paper);
|
||||
border: 2px solid #fff;
|
||||
border-right-color: #9aa3ae;
|
||||
border-bottom-color: #9aa3ae;
|
||||
min-width: 0;
|
||||
}
|
||||
.panel h3 {
|
||||
margin: 0;
|
||||
padding: 6px 10px;
|
||||
background: var(--bezel);
|
||||
border-bottom: 1px solid var(--bezel-lo);
|
||||
font: 700 13px/1.3 var(--sans);
|
||||
}
|
||||
.panel .body { padding: 10px; }
|
||||
.meta-line { margin: 0 0 8px; font: 12.5px/1.45 var(--mono); color: var(--mute); overflow-wrap: anywhere; }
|
||||
.tree { margin: 0; padding: 0; list-style: none; font: 13px/1.35 var(--mono); }
|
||||
.tree ul { margin: 0; padding: 0 0 0 14px; list-style: none; border-left: 1px dotted #b7c0ca; }
|
||||
.part {
|
||||
display: flex; align-items: baseline; gap: 8px; width: 100%;
|
||||
text-align: left; background: transparent; border: 0; border-left: 3px solid transparent;
|
||||
padding: 4px 6px; font: inherit; color: var(--ink); cursor: pointer; touch-action: manipulation;
|
||||
}
|
||||
.part:hover, .part.on { background: #fff6d8; border-left-color: var(--amber); }
|
||||
.part .nm { overflow-wrap: anywhere; }
|
||||
.part .sz { margin-left: auto; color: var(--mute); font-size: 11px; white-space: nowrap; }
|
||||
.dot { width: 8px; height: 8px; flex: none; background: #8aa0b8; }
|
||||
.dot.leak { background: var(--danger); }
|
||||
.dot.ok { background: var(--ok); }
|
||||
.dot.bin { background: #6a5acd; }
|
||||
.folder-name { padding: 5px 6px 2px; font-weight: 700; color: var(--title); }
|
||||
.findings { display: grid; gap: 8px; }
|
||||
.finding { border: 1px solid var(--rule); background: #fff; padding: 8px 10px; font-size: 14px; }
|
||||
.finding .tag {
|
||||
display: inline-block; margin-right: 6px; padding: 0 6px;
|
||||
font: 700 11px/1.6 var(--mono); letter-spacing: .04em; text-transform: uppercase;
|
||||
}
|
||||
.sev-leak { background: var(--danger-bg); }
|
||||
.sev-leak .tag { background: var(--danger); color: #fff; }
|
||||
.sev-warn { background: var(--warn-bg); }
|
||||
.sev-warn .tag { background: var(--warn); color: #fff; }
|
||||
.sev-info { background: var(--info-bg); }
|
||||
.sev-info .tag { background: var(--info); color: #fff; }
|
||||
.sev-ok { background: var(--ok-bg); }
|
||||
.sev-ok .tag { background: var(--ok); color: #fff; }
|
||||
.finding p { margin: 4px 0 0; }
|
||||
.finding code, .anno code, .limits code, .ref code { font: 12px/1.4 var(--mono); background: #eef2f6; padding: 0 4px; }
|
||||
.raw-wrap { display: grid; gap: 8px; }
|
||||
.anno { margin: 0; font-size: 14px; color: var(--ink-soft); }
|
||||
.verdict { display: inline-block; margin-top: 4px; padding: 1px 7px; font: 700 12px/1.6 var(--mono); }
|
||||
.verdict.no { background: var(--danger); color: #fff; }
|
||||
.verdict.careful { background: var(--warn); color: #fff; }
|
||||
.verdict.yes { background: var(--ok); color: #fff; }
|
||||
pre.xml {
|
||||
margin: 0; max-height: 340px; overflow: auto; padding: 10px;
|
||||
background: #11161c; color: #e7eef6; font: 12px/1.45 var(--mono);
|
||||
white-space: pre-wrap; overflow-wrap: anywhere;
|
||||
}
|
||||
.err { color: var(--danger); font-weight: 700; }
|
||||
.ref { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
.ref th, .ref td { text-align: left; vertical-align: top; padding: 8px; border-bottom: 1px solid var(--rule); }
|
||||
.ref th { font: 700 12px/1.3 var(--mono); letter-spacing: .04em; text-transform: uppercase; color: var(--mute); }
|
||||
.repos { display: grid; }
|
||||
.repo {
|
||||
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 16px;
|
||||
padding: 12px 0; border-top: 1px solid var(--rule);
|
||||
}
|
||||
.repo a { font: 600 15px/1.3 var(--mono); }
|
||||
.stars { font: 13px/1 var(--mono); color: var(--mute); white-space: nowrap; }
|
||||
.stars b { color: var(--danger); }
|
||||
.repo .note { grid-column: 1 / -1; margin: 0; color: var(--mute); font-size: 13.5px; }
|
||||
.tiers { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
||||
.tier {
|
||||
background: var(--paper);
|
||||
border: 2px solid #fff; border-right-color: #8b95a3; border-bottom-color: #8b95a3;
|
||||
padding: 14px 14px 12px; display: flex; flex-direction: column; min-width: 0;
|
||||
}
|
||||
.tier.featured { background: #fffdf6; outline: 2px solid var(--amber); }
|
||||
.tier-name { margin: 0 0 6px; font: 700 12px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; color: var(--title); }
|
||||
.price { margin: 0; font: 700 26px/1.15 var(--sans); letter-spacing: -.02em; }
|
||||
.price small { display: block; margin-top: 4px; font: 400 13px/1.35 var(--sans); color: var(--mute); }
|
||||
.for-whom { margin: 8px 0; color: var(--mute); font-size: 13.5px; }
|
||||
.tier ul { margin: 0 0 14px; padding-left: 1.1em; font-size: 14px; }
|
||||
.tier .btn { margin-top: auto; text-align: center; }
|
||||
.nothing-charged { margin: 14px 0 0; text-align: center; font-style: italic; font-size: 17px; }
|
||||
.limits { margin: 0; padding-left: 1.2em; max-width: 70ch; }
|
||||
.limits li { margin: 0 0 8px; }
|
||||
.steps { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
||||
.step { background: var(--paper); border: 1px solid var(--rule); padding: 12px; }
|
||||
.step .n { margin: 0; font: 700 28px/1 var(--mono); color: var(--title); }
|
||||
.step h3 { margin: 6px 0; font-size: 16px; }
|
||||
.step p { margin: 0; color: var(--ink-soft); font-size: 14px; }
|
||||
footer.desk-foot { padding: 8px 0 40px; color: #c5d7ea; font-size: 13.5px; }
|
||||
footer.desk-foot a { color: #fff; }
|
||||
footer.desk-foot p { margin: 0 0 6px; }
|
||||
|
||||
/* Responsive fixes for mobile */
|
||||
@media (max-width: 860px) {
|
||||
.layout, .tiers, .steps { grid-template-columns: 1fr; }
|
||||
.repo { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.face { padding: 10px; }
|
||||
pre.xml { max-height: 240px; }
|
||||
.hero-actions { flex-direction: column; align-items: flex-start; gap: 12px; }
|
||||
.btn { width: 100%; text-align: center; }
|
||||
header.slim-header .btn { width: auto; }
|
||||
}
|
||||
|
||||
/* Fix horizontal scrolling issue */
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Ensure content doesn't exceed viewport */
|
||||
.wrap {
|
||||
max-width: 100%;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* Reduce hero padding to move product higher */
|
||||
header.hero {
|
||||
padding: 16px 0 8px;
|
||||
}
|
||||
|
||||
/* Slim header: one primary early-access button, before #try and #pricing */
|
||||
header.slim-header {
|
||||
padding: 8px 0;
|
||||
text-align: left;
|
||||
}
|
||||
header.slim-header .wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
header.slim-header .kicker {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
header.slim-header .btn { flex: none; }
|
||||
|
||||
/* Move intro content below the product */
|
||||
.intro-content {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
background: rgba(28, 58, 95, 0.7);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* finish: the reference table scrolls inside its own box on a phone instead of widening the page */
|
||||
.face{overflow-x:auto}
|
||||
table.ref td{overflow-wrap:anywhere}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="slim-header">
|
||||
<div class="wrap">
|
||||
<p class="kicker">Time · Venture · Docscope</p>
|
||||
<a class="btn primary" href="https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&title=Early+access%3A+Docscope&venture=docscope&plan=Pro+%28founding%29">Get early access</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Product section moved to start -->
|
||||
<section id="try">
|
||||
<div class="wrap">
|
||||
<div class="win" id="app">
|
||||
<div class="titlebar">
|
||||
<span class="glyph" aria-hidden="true"></span>
|
||||
<span class="caption" id="win-caption">Docscope — waiting for a package</span>
|
||||
<span class="tb-btn" aria-hidden="true">_</span>
|
||||
<span class="tb-btn" aria-hidden="true">□</span>
|
||||
<span class="tb-btn" aria-hidden="true">×</span>
|
||||
</div>
|
||||
<div class="menu" aria-hidden="true"><span><u>F</u>ile</span><span><u>V</u>iew</span><span><u>P</u>arts</span><span><u>H</u>elp</span></div>
|
||||
<div class="face">
|
||||
<div class="drop" id="drop">
|
||||
<strong>Drag a .docx, .xlsx or .pptx here</strong>
|
||||
<p>Unzipped locally with <code>DecompressionStream</code>. The page works offline once loaded. The list below is the ZIP central directory, verbatim — nothing is invented.</p>
|
||||
<input class="file-input" id="file" type="file" accept=".docx,.xlsx,.pptx,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.openxmlformats-officedocument.presentationml.presentation" aria-label="Choose an Office file">
|
||||
</div>
|
||||
<p class="meta-line" id="pkg-meta">Loading the preloaded sample through the same unzipper…</p>
|
||||
<div class="layout">
|
||||
<div class="panel">
|
||||
<h3>Exploded package</h3>
|
||||
<div class="body" id="tree"><p class="meta-line">No parts yet.</p></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>Leak report</h3>
|
||||
<div class="body" id="report"><p class="meta-line">No findings yet.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel" style="margin-top:12px">
|
||||
<h3 id="part-title">Part inspector</h3>
|
||||
<div class="body raw-wrap" id="inspector">
|
||||
<p class="anno">Click any part in the diagram. Raw XML (or a hex preview of a binary part) appears here, with a plain-English note and a safe-to-send verdict.</p>
|
||||
<pre class="xml" id="raw">(nothing selected)</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="statusline">
|
||||
<span id="status-left">client-side · no upload</span>
|
||||
<span id="status-right">ECMA-376</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Intro content moved below product -->
|
||||
<div class="intro-content">
|
||||
<div class="wrap">
|
||||
<h1>Doc<span>scope</span></h1>
|
||||
<p class="promise">Drop a .docx, .xlsx or .pptx and see everything still inside it — author names, tracked deletions, thumbnails, comments — dissected on this machine. Nothing is uploaded.</p>
|
||||
<div class="hero-actions">
|
||||
<a class="try-link" href="#try">Inspect the sample ↑</a>
|
||||
</div>
|
||||
<p class="fineprint">Opens a public GitHub issue. No email, no payment. Nothing is charged today.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<section id="reference">
|
||||
<div class="wrap">
|
||||
<p class="sec-label">Reference</p>
|
||||
<div class="win">
|
||||
<div class="titlebar"><span class="glyph" aria-hidden="true"></span><span class="caption">OOXML parts this page actually handles</span></div>
|
||||
<div class="face">
|
||||
<p class="lede">Only parts the inspector reads are listed. Each link goes to the ECMA-376 5th edition page. Parts we do not understand still appear in the diagram, labelled as unhandled.</p>
|
||||
<table class="ref">
|
||||
<thead><tr><th>Part</th><th>What we read</th><th>Spec</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>[Content_Types].xml</code></td><td>Default and override content types for every part</td><td><a href="https://ecma-international.org/publications-and-standards/standards/ecma-376/">ECMA-376 Part 2, §10.1.2</a></td></tr>
|
||||
<tr><td><code>_rels/.rels</code> and <code>*.rels</code></td><td>Package and part relationships, including the thumbnail relationship</td><td><a href="https://ecma-international.org/publications-and-standards/standards/ecma-376/">ECMA-376 Part 2, §9.2 / §10.2</a></td></tr>
|
||||
<tr><td><code>docProps/core.xml</code></td><td><code>dc:creator</code>, <code>cp:lastModifiedBy</code>, title, revision, created, modified</td><td><a href="https://ecma-international.org/publications-and-standards/standards/ecma-376/">ECMA-376 Part 2, §11.1 (Dublin Core)</a></td></tr>
|
||||
<tr><td><code>docProps/app.xml</code></td><td>Application, company, template, pages / words</td><td><a href="https://ecma-international.org/publications-and-standards/standards/ecma-376/">ECMA-376 Part 1, §22.2 extended properties</a></td></tr>
|
||||
<tr><td><code>docProps/thumbnail.jpeg</code></td><td>Embedded preview, plus JPEG APP1 EXIF text</td><td><a href="https://ecma-international.org/publications-and-standards/standards/ecma-376/">ECMA-376 Part 2, thumbnail relationship</a></td></tr>
|
||||
<tr><td><code>word/document.xml</code></td><td><code>w:ins</code> / <code>w:del</code> tracked changes, comment markers, visible text</td><td><a href="https://ecma-international.org/publications-and-standards/standards/ecma-376/">ECMA-376 Part 1, §17.13 revisions</a></td></tr>
|
||||
<tr><td><code>word/comments.xml</code></td><td>Comment author, date, and text</td><td><a href="https://ecma-international.org/publications-and-standards/standards/ecma-376/">ECMA-376 Part 1, §17.13.4</a></td></tr>
|
||||
<tr><td><code>word/people.xml</code></td><td>Presence authors and user ids</td><td><a href="https://ecma-international.org/publications-and-standards/standards/ecma-376/">ECMA-376 Part 1, people part</a></td></tr>
|
||||
<tr><td><code>word/styles.xml</code></td><td>Style ids and names (annotation only)</td><td><a href="https://ecma-international.org/publications-and-standards/standards/ecma-376/">ECMA-376 Part 1, §17.7</a></td></tr>
|
||||
<tr><td><code>word/media/*</code></td><td>Embedded images; JPEG EXIF and PNG tEXt / iTXt</td><td><a href="https://ecma-international.org/publications-and-standards/standards/ecma-376/">ECMA-376 Part 1, §15.2.14 images</a></td></tr>
|
||||
<tr><td><code>xl/workbook.xml</code></td><td>Sheet names and <code>state="hidden"</code> / <code>veryHidden</code></td><td><a href="https://ecma-international.org/publications-and-standards/standards/ecma-376/">ECMA-376 Part 1, §18.2 workbook</a></td></tr>
|
||||
<tr><td><code>ppt/presentation.xml</code></td><td>Slides with <code>show="0"</code></td><td><a href="https://ecma-international.org/publications-and-standards/standards/ecma-376/">ECMA-376 Part 1, §19.2 presentation</a></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="how">
|
||||
<div class="wrap">
|
||||
<p class="sec-label">How it works</p>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<p class="n">1</p>
|
||||
<h3>Drop the package</h3>
|
||||
<p>A .docx, .xlsx or .pptx is a ZIP. The page reads the central directory and inflates each part with the browser's <code>DecompressionStream</code>. Stored and deflate only — the methods Office writes.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<p class="n">2</p>
|
||||
<h3>Read the leak report</h3>
|
||||
<p>Core properties, tracked insertions and deletions, comments, people.xml, thumbnail parts, JPEG EXIF, PNG text, and hidden sheets or slides are flagged from the XML itself.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<p class="n">3</p>
|
||||
<h3>Open any part</h3>
|
||||
<p>Click a name in the diagram. You get the raw XML, or a hex preview if the part is binary, plus a plain-English note on what it does and whether it is safe to send.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="why">
|
||||
<div class="wrap">
|
||||
<p class="sec-label">Why this exists</p>
|
||||
<div class="win">
|
||||
<div class="titlebar"><span class="glyph" aria-hidden="true"></span><span class="caption">Why this exists — the repos this rides</span></div>
|
||||
<div class="face">
|
||||
<p class="lede">People are generating Office files from plain text again. Docscope rides the trending repos wordcraft, gridcraft and deckcraft: builders starring those projects need to see what the package they just wrote actually contains before it is emailed.</p>
|
||||
<div class="repos">
|
||||
<div class="repo">
|
||||
<a href="https://github.com/storytold/wordcraft">storytold/wordcraft</a>
|
||||
<span class="stars">★ <b>5,496</b> stars</span>
|
||||
<p class="note">Plain text in, .docx out. Docscope is the check on the other side: creator, last-modified-by, comments and tracked-change remnants in the file that left the tool.</p>
|
||||
</div>
|
||||
<div class="repo">
|
||||
<a href="https://github.com/storytold/gridcraft">storytold/gridcraft</a>
|
||||
<span class="stars">★ <b>2,749</b> stars</span>
|
||||
<p class="note">Spreadsheets from structured text. Hidden sheets (<code>state="hidden"</code>) and workbook metadata are exactly what a generated .xlsx can still carry.</p>
|
||||
</div>
|
||||
<div class="repo">
|
||||
<a href="https://github.com/storytold/deckcraft">storytold/deckcraft</a>
|
||||
<span class="stars">★ <b>1,804</b> stars</span>
|
||||
<p class="note">Decks from text. A generated .pptx still has a thumbnail, author names, and slides with <code>show="0"</code> that a client can recover.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="pricing">
|
||||
<div class="wrap">
|
||||
<p class="sec-label">Pricing</p>
|
||||
<div class="tiers">
|
||||
<div class="tier">
|
||||
<p class="tier-name">Free</p>
|
||||
<p class="price">$0</p>
|
||||
<p class="for-whom">Anyone checking a file before it leaves their machine.</p>
|
||||
<ul>
|
||||
<li>Full local dissection of .docx, .xlsx and .pptx</li>
|
||||
<li>Interactive exploded diagram of every OOXML part</li>
|
||||
<li>Leak report: authors, tracked insertions and deletions, comments, thumbnails, EXIF, hidden sheets and slides</li>
|
||||
<li>Raw XML with a plain-English note and a safe-to-send verdict</li>
|
||||
<li>Works offline once the page has loaded</li>
|
||||
</ul>
|
||||
<a class="btn" href="#try">Inspect a file</a>
|
||||
</div>
|
||||
<div class="tier featured">
|
||||
<p class="tier-name">Pro (founding)</p>
|
||||
<p class="price">$5<small>/mo founding price — nothing charged today</small></p>
|
||||
<p class="for-whom">The consultant or lawyer who sends client files every week.</p>
|
||||
<ul>
|
||||
<li>One-click scrub that rewrites the file clean</li>
|
||||
<li>Batch scanning of whole folders</li>
|
||||
<li>Exportable leak report (PDF/HTML) for engagement records</li>
|
||||
<li>Founding price locked for early-access members</li>
|
||||
</ul>
|
||||
<a class="btn primary" href="https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&title=Early+access%3A+Docscope&venture=docscope&plan=Pro+%28founding%29">Get early access</a>
|
||||
</div>
|
||||
<div class="tier">
|
||||
<p class="tier-name">Team/CI (founding)</p>
|
||||
<p class="price">$19<small>/mo per repo — nothing charged today</small></p>
|
||||
<p class="for-whom">Dev teams and firms that want leaks blocked, not just found.</p>
|
||||
<ul>
|
||||
<li>Pre-commit hook and CI check that fails on leaky attachments</li>
|
||||
<li>Shared scrub policies</li>
|
||||
<li>Everything in Pro for the team</li>
|
||||
</ul>
|
||||
<a class="btn" href="https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&title=Early+access%3A+Docscope&venture=docscope&plan=Team%2FCI+%28founding%29">Get early access</a>
|
||||
</div>
|
||||
</div>
|
||||
<p class="nothing-charged">Nothing is charged today.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="limits" class="honest-limits">
|
||||
<div class="wrap">
|
||||
<p class="sec-label">Honest limits</p>
|
||||
<div class="win">
|
||||
<div class="titlebar"><span class="glyph" aria-hidden="true"></span><span class="caption">Honest limits — what this page does not do</span></div>
|
||||
<div class="face">
|
||||
<ul class="limits">
|
||||
<li>Does not yet support PDF or DWG files. Office Open XML packages only: .docx, .xlsx, .pptx.</li>
|
||||
<li>ZIP method 0 (stored) and method 8 (deflate) only — the methods Office writes. Encrypted packages are not decrypted.</li>
|
||||
<li>EXIF reading covers JPEG APP1 text (ASCII tags such as Artist and DateTime) and PNG tEXt/iTXt. It does not decode every TIFF IFD type.</li>
|
||||
<li>No one-click scrubbing in the free version. Inspection shows the leak; it does not rewrite the file.</li>
|
||||
<li>Batch scanning is only in the Pro version. This page takes one file at a time.</li>
|
||||
<li>CI and pre-commit checks are only in the Team/CI version.</li>
|
||||
<li>The parsed file list is the ZIP central directory. If a part is missing from that listing, it is missing here too — we do not fill in parts the package does not have.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="desk-foot">
|
||||
<div class="wrap">
|
||||
<p>Built by <a href="https://teahub.xyz/teahubxyz/time">Time, an agent-run venture studio on GitHub</a>.</p>
|
||||
<p><a href="https://teahub.xyz/teahubxyz/time/issues/14">Want the next version? 👍 the signal issue #14</a></p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var SAMPLE_NAME = "Hale-engagement-letter.docx";
|
||||
var SAMPLE_NOTE = "Preloaded sample, decoded from sample.b64 and unzipped by the same code as a file you drop — not a canned report.";
|
||||
var ECMA = "https://ecma-international.org/publications-and-standards/standards/ecma-376/";
|
||||
|
||||
var PARTS = {
|
||||
"[Content_Types].xml": { title: "Content Types stream", spec: "ECMA-376 Part 2, §10.1.2", note: "Names the media type of every part. Office uses it to decide which XML vocabulary a part speaks. It is not itself a leak, but a missing or odd override is a sign the package was hand-edited.", send: "yes" },
|
||||
"docProps/core.xml": { title: "Core properties", spec: "ECMA-376 Part 2, §11.1", note: "Dublin Core metadata: title, creator, lastModifiedBy, revision, created and modified. This is the usual place a previous client's name or your own name survives a Save As.", send: "no" },
|
||||
"docProps/app.xml": { title: "Extended properties", spec: "ECMA-376 Part 1, §22.2", note: "Application, company, template name, and page or word counts. The template name often identifies the firm or the prior engagement the file was cloned from.", send: "careful" },
|
||||
"docProps/thumbnail.jpeg": { title: "Package thumbnail", spec: "ECMA-376 Part 2, thumbnail relationship", note: "A JPEG preview of the document, stored even when you never inserted an image. It can show a page you have since deleted, and its EXIF can name the author.", send: "no" },
|
||||
"word/document.xml": { title: "Main document part", spec: "ECMA-376 Part 1, §17.13", note: "The visible text, plus tracked insertions (w:ins), tracked deletions (w:del / w:delText) and comment markers. Accepting changes in Word is not the same as this XML being clean.", send: "careful" },
|
||||
"word/comments.xml": { title: "Comments part", spec: "ECMA-376 Part 1, §17.13.4", note: "Comment authors, timestamps and the comment text. Deleting a comment in the UI does not always remove this part.", send: "no" },
|
||||
"word/people.xml": { title: "People part", spec: "ECMA-376 people part", note: "Authors who have touched the document, sometimes with a provider user id or email. Easy to miss because Word never shows this file.", send: "no" },
|
||||
"word/styles.xml": { title: "Styles part", spec: "ECMA-376 Part 1, §17.7", note: "Paragraph and character styles. Rarely a direct leak; style names copied from a client template can still identify the source firm.", send: "yes" },
|
||||
"xl/workbook.xml": { title: "Workbook part", spec: "ECMA-376 Part 1, §18.2", note: "Sheet index. state=\"hidden\" or state=\"veryHidden\" marks a sheet the recipient will not see in the tab bar but can unhide.", send: "careful" },
|
||||
"ppt/presentation.xml": { title: "Presentation part", spec: "ECMA-376 Part 1, §19.2", note: "Slide list. show=\"0\" marks a hidden slide. Hidden is not deleted — the slide XML is still in the package.", send: "careful" }
|
||||
};
|
||||
|
||||
var treeEl = document.getElementById("tree");
|
||||
var reportEl = document.getElementById("report");
|
||||
var rawEl = document.getElementById("raw");
|
||||
var inspectorEl = document.getElementById("inspector");
|
||||
var partTitle = document.getElementById("part-title");
|
||||
var metaEl = document.getElementById("pkg-meta");
|
||||
var captionEl = document.getElementById("win-caption");
|
||||
var statusLeft = document.getElementById("status-left");
|
||||
var statusRight = document.getElementById("status-right");
|
||||
var dropEl = document.getElementById("drop");
|
||||
var fileEl = document.getElementById("file");
|
||||
|
||||
function esc(s) { return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); }
|
||||
function u16(b, o) { return b[o] | (b[o + 1] << 8); }
|
||||
function u32(b, o) { return (b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] << 24)) >>> 0; }
|
||||
|
||||
function b64ToBytes(b64) {
|
||||
var bin = atob(b64.replace(/\s+/g, ""));
|
||||
var out = new Uint8Array(bin.length);
|
||||
for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function findEocd(bytes) {
|
||||
var start = Math.max(0, bytes.length - 22 - 65535);
|
||||
for (var i = bytes.length - 22; i >= start; i--) if (u32(bytes, i) === 0x06054b50) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
function listCentralDirectory(bytes) {
|
||||
var eocd = findEocd(bytes);
|
||||
if (eocd < 0) throw new Error("Not a ZIP package (no end-of-central-directory record).");
|
||||
var count = u16(bytes, eocd + 10);
|
||||
var cdOff = u32(bytes, eocd + 16);
|
||||
var entries = [];
|
||||
var p = cdOff;
|
||||
for (var i = 0; i < count; i++) {
|
||||
if (p + 46 > bytes.length || u32(bytes, p) !== 0x02014b50) throw new Error("Central directory is truncated or not a ZIP.");
|
||||
var method = u16(bytes, p + 10);
|
||||
var compSize = u32(bytes, p + 20);
|
||||
var nameLen = u16(bytes, p + 28);
|
||||
var extraLen = u16(bytes, p + 30);
|
||||
var commentLen = u16(bytes, p + 32);
|
||||
var localOff = u32(bytes, p + 42);
|
||||
var name = new TextDecoder("utf-8").decode(bytes.subarray(p + 46, p + 46 + nameLen));
|
||||
entries.push({ name: name, method: method, compSize: compSize, localOff: localOff });
|
||||
p += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function inflateRaw(comp) {
|
||||
if (typeof DecompressionStream !== "function") {
|
||||
return Promise.reject(new Error("This browser has no DecompressionStream. Use a current Chrome, Edge, Firefox or Safari."));
|
||||
}
|
||||
var ds = new DecompressionStream("deflate-raw");
|
||||
return new Response(new Blob([comp]).stream().pipeThrough(ds)).arrayBuffer().then(function (buf) {
|
||||
return new Uint8Array(buf);
|
||||
});
|
||||
}
|
||||
|
||||
function readLocal(bytes, entry) {
|
||||
var lp = entry.localOff;
|
||||
if (lp + 30 > bytes.length || u32(bytes, lp) !== 0x04034b50) throw new Error("Local header missing for " + entry.name);
|
||||
var method = u16(bytes, lp + 8);
|
||||
var nlen = u16(bytes, lp + 26);
|
||||
var elen = u16(bytes, lp + 28);
|
||||
var comp = bytes.subarray(lp + 30 + nlen + elen, lp + 30 + nlen + elen + entry.compSize);
|
||||
if (method === 0) return Promise.resolve(comp.slice());
|
||||
if (method === 8) return inflateRaw(comp);
|
||||
return Promise.reject(new Error(entry.name + " uses ZIP method " + method + ". Docscope only inflates stored (0) and deflate (8)."));
|
||||
}
|
||||
|
||||
function unzipPackage(bytes) {
|
||||
var entries = listCentralDirectory(bytes);
|
||||
var chain = Promise.resolve([]);
|
||||
entries.forEach(function (entry) {
|
||||
chain = chain.then(function (acc) {
|
||||
if (entry.name.endsWith("/")) {
|
||||
acc.push({ name: entry.name, method: entry.method, size: 0, bytes: new Uint8Array(0), dir: true });
|
||||
return acc;
|
||||
}
|
||||
return readLocal(bytes, entry).then(function (data) {
|
||||
acc.push({ name: entry.name, method: entry.method, size: data.length, bytes: data, dir: false });
|
||||
return acc;
|
||||
});
|
||||
});
|
||||
});
|
||||
return chain.then(function (parts) { return { entries: entries, parts: parts }; });
|
||||
}
|
||||
|
||||
function asText(bytes) { return new TextDecoder("utf-8", { fatal: false }).decode(bytes); }
|
||||
function parseXml(text) {
|
||||
var doc = new DOMParser().parseFromString(text, "application/xml");
|
||||
return doc.querySelector("parsererror") ? null : doc;
|
||||
}
|
||||
function localName(el) { return el.localName || el.nodeName; }
|
||||
function all(doc, name) {
|
||||
if (!doc) return [];
|
||||
var out = [], nodes = doc.getElementsByTagName("*");
|
||||
for (var i = 0; i < nodes.length; i++) if (localName(nodes[i]) === name) out.push(nodes[i]);
|
||||
return out;
|
||||
}
|
||||
function textOf(el) { return (el.textContent || "").replace(/\s+/g, " ").trim(); }
|
||||
function attr(el, name) {
|
||||
if (el.hasAttribute(name)) return el.getAttribute(name);
|
||||
for (var i = 0; i < el.attributes.length; i++) {
|
||||
if (el.attributes[i].localName === name || el.attributes[i].name === name) return el.attributes[i].value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
function looksText(bytes) {
|
||||
if (!bytes.length) return false;
|
||||
var n = Math.min(bytes.length, 800), odd = 0;
|
||||
for (var i = 0; i < n; i++) {
|
||||
var c = bytes[i];
|
||||
if (c === 0) return false;
|
||||
if (c < 9 || (c > 13 && c < 32)) odd++;
|
||||
}
|
||||
return odd / n < 0.05;
|
||||
}
|
||||
function hexPreview(bytes) {
|
||||
var n = Math.min(bytes.length, 256), lines = [];
|
||||
for (var i = 0; i < n; i += 16) {
|
||||
var hex = [], asc = "";
|
||||
for (var j = 0; j < 16 && i + j < n; j++) {
|
||||
var c = bytes[i + j];
|
||||
hex.push((c < 16 ? "0" : "") + c.toString(16));
|
||||
asc += (c >= 32 && c < 127) ? String.fromCharCode(c) : ".";
|
||||
}
|
||||
lines.push(hex.join(" ").padEnd(47, " ") + " " + asc);
|
||||
}
|
||||
if (bytes.length > n) lines.push("… " + (bytes.length - n) + " more bytes");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function readExif(bytes) {
|
||||
var tags = [];
|
||||
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return tags;
|
||||
var o = 2;
|
||||
while (o + 4 < bytes.length) {
|
||||
if (bytes[o] !== 0xff) break;
|
||||
var marker = bytes[o + 1];
|
||||
if (marker === 0xd8 || marker === 0xd9) { o += 2; continue; }
|
||||
var len = (bytes[o + 2] << 8) | bytes[o + 3];
|
||||
if (len < 2 || o + 2 + len > bytes.length) break;
|
||||
if (marker === 0xe1 && asText(bytes.subarray(o + 4, o + 10)).indexOf("Exif") === 0) tags = tags.concat(readTiff(bytes.subarray(o + 10, o + 2 + len)));
|
||||
o += 2 + len;
|
||||
if (marker === 0xda) break;
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
function readTiff(tiff) {
|
||||
var out = [];
|
||||
if (tiff.length < 8) return out;
|
||||
var le = tiff[0] === 0x49 && tiff[1] === 0x49;
|
||||
var be = tiff[0] === 0x4d && tiff[1] === 0x4d;
|
||||
if (!le && !be) return out;
|
||||
function r16(off) { return le ? tiff[off] | (tiff[off + 1] << 8) : (tiff[off] << 8) | tiff[off + 1]; }
|
||||
function r32(off) {
|
||||
return le ? (tiff[off] | (tiff[off + 1] << 8) | (tiff[off + 2] << 16) | (tiff[off + 3] << 24)) >>> 0
|
||||
: ((tiff[off] << 24) | (tiff[off + 1] << 16) | (tiff[off + 2] << 8) | tiff[off + 3]) >>> 0;
|
||||
}
|
||||
var names = { 0x010e: "ImageDescription", 0x010f: "Make", 0x0110: "Model", 0x0131: "Software", 0x0132: "DateTime", 0x013b: "Artist", 0x8298: "Copyright", 0x9003: "DateTimeOriginal" };
|
||||
function walk(ifd, depth) {
|
||||
if (depth > 3 || ifd + 2 > tiff.length) return;
|
||||
var n = r16(ifd);
|
||||
for (var i = 0; i < n; i++) {
|
||||
var e = ifd + 2 + i * 12;
|
||||
if (e + 12 > tiff.length) break;
|
||||
var tag = r16(e), typ = r16(e + 2), count = r32(e + 4);
|
||||
if (typ === 2 && count > 1 && count < 400) {
|
||||
var start = count <= 4 ? e + 8 : r32(e + 8);
|
||||
if (start >= 0 && start + count <= tiff.length) {
|
||||
var s = asText(tiff.subarray(start, start + count - 1)).replace(/\u0000/g, "").trim();
|
||||
if (s) out.push({ tag: names[tag] || ("0x" + tag.toString(16)), value: s });
|
||||
}
|
||||
}
|
||||
if (tag === 0x8769 || tag === 0x8825) {
|
||||
var sub = r32(e + 8);
|
||||
if (sub && sub !== ifd) walk(sub, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(r32(4), 0);
|
||||
return out;
|
||||
}
|
||||
function readPngText(bytes) {
|
||||
var out = [];
|
||||
if (bytes.length < 8 || bytes[0] !== 0x89 || bytes[1] !== 0x50) return out;
|
||||
var o = 8;
|
||||
while (o + 12 <= bytes.length) {
|
||||
var len = ((bytes[o] << 24) | (bytes[o + 1] << 16) | (bytes[o + 2] << 8) | bytes[o + 3]) >>> 0;
|
||||
var type = asText(bytes.subarray(o + 4, o + 8));
|
||||
if (o + 12 + len > bytes.length) break;
|
||||
var data = bytes.subarray(o + 8, o + 8 + len);
|
||||
if (type === "tEXt" || type === "iTXt") {
|
||||
var z = data.indexOf(0);
|
||||
if (z > 0) out.push({ key: asText(data.subarray(0, z)), value: asText(data.subarray(z + 1)).replace(/\u0000/g, " ").trim() });
|
||||
}
|
||||
o += 12 + len;
|
||||
if (type === "IEND") break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function partByName(parts, name) {
|
||||
for (var i = 0; i < parts.length; i++) if (parts[i].name === name) return parts[i];
|
||||
return null;
|
||||
}
|
||||
|
||||
function analyze(pkg) {
|
||||
var findings = [], flags = {};
|
||||
function flag(name, sev) { if (!flags[name] || sev === "leak") flags[name] = sev; }
|
||||
function add(sev, label, html, partName) {
|
||||
findings.push({ sev: sev, label: label, html: html });
|
||||
if (partName) flag(partName, sev);
|
||||
}
|
||||
var core = partByName(pkg.parts, "docProps/core.xml");
|
||||
if (core) {
|
||||
var cdoc = parseXml(asText(core.bytes));
|
||||
var creator = all(cdoc, "creator").map(textOf).filter(Boolean);
|
||||
var modifiedBy = all(cdoc, "lastModifiedBy").map(textOf).filter(Boolean);
|
||||
var title = all(cdoc, "title").map(textOf).filter(Boolean);
|
||||
var modified = all(cdoc, "modified").map(textOf).filter(Boolean);
|
||||
if (creator.length) add("leak", "Author", "dc:creator is <strong>" + esc(creator.join(", ")) + "</strong>. That name travels with the file.", "docProps/core.xml");
|
||||
if (modifiedBy.length) add("leak", "Last modified by", "cp:lastModifiedBy is <strong>" + esc(modifiedBy.join(", ")) + "</strong>" + (modified.length ? " (" + esc(modified.join(", ")) + ")" : "") + ".", "docProps/core.xml");
|
||||
if (title.length) add("info", "Title", "dc:title is “" + esc(title.join(" / ")) + "”.", "docProps/core.xml");
|
||||
if (!creator.length && !modifiedBy.length) add("ok", "Core properties", "No creator or lastModifiedBy in docProps/core.xml.", "docProps/core.xml");
|
||||
} else add("ok", "Core properties", "No docProps/core.xml in this package.");
|
||||
|
||||
var app = partByName(pkg.parts, "docProps/app.xml");
|
||||
if (app) {
|
||||
var adoc = parseXml(asText(app.bytes));
|
||||
var company = all(adoc, "Company").map(textOf).filter(Boolean);
|
||||
var template = all(adoc, "Template").map(textOf).filter(Boolean);
|
||||
if (company.length) add("warn", "Company", "Extended property Company is <strong>" + esc(company.join(", ")) + "</strong>.", "docProps/app.xml");
|
||||
if (template.length) add("warn", "Template", "Built from template <strong>" + esc(template.join(", ")) + "</strong>.", "docProps/app.xml");
|
||||
}
|
||||
|
||||
pkg.parts.forEach(function (part) {
|
||||
if (part.dir || !/\.xml$/i.test(part.name)) return;
|
||||
var text = asText(part.bytes);
|
||||
if (text.indexOf("<") === -1) return;
|
||||
var doc = parseXml(text);
|
||||
if (!doc) return;
|
||||
var ins = all(doc, "ins"), del = all(doc, "del");
|
||||
if (ins.length || del.length) {
|
||||
var bits = [];
|
||||
ins.slice(0, 6).forEach(function (el) { bits.push("insertion by " + esc(attr(el, "author") || "unknown") + (textOf(el) ? ": “" + esc(textOf(el).slice(0, 140)) + "”" : "")); });
|
||||
del.slice(0, 6).forEach(function (el) { bits.push("deletion by " + esc(attr(el, "author") || "unknown") + (textOf(el) ? ": “" + esc(textOf(el).slice(0, 140)) + "”" : "")); });
|
||||
add("leak", "Tracked changes", ins.length + " insertion" + (ins.length === 1 ? "" : "s") + " (w:ins) and " + del.length + " deletion" + (del.length === 1 ? "" : "s") + " (w:del) still in " + esc(part.name) + ". " + bits.join("; ") + ".", part.name);
|
||||
}
|
||||
var comments = all(doc, "comment").filter(function (el) { return attr(el, "author") || textOf(el); });
|
||||
if (comments.length && /comment/i.test(part.name)) {
|
||||
add("leak", "Comments", comments.length + " comment" + (comments.length === 1 ? "" : "s") + " in " + esc(part.name) + ". " + comments.slice(0, 8).map(function (el) {
|
||||
return esc(attr(el, "author") || "unknown") + ": “" + esc(textOf(el).slice(0, 160)) + "”";
|
||||
}).join(" · "), part.name);
|
||||
}
|
||||
if (/people\.xml$/i.test(part.name)) {
|
||||
var people = all(doc, "person");
|
||||
if (people.length) add("leak", "People", people.length + " person record" + (people.length === 1 ? "" : "s") + " in people.xml: <strong>" + esc(people.map(function (el) { return attr(el, "author") || textOf(el); }).filter(Boolean).join(", ")) + "</strong>.", part.name);
|
||||
}
|
||||
if (/workbook\.xml$/i.test(part.name)) {
|
||||
var hidden = all(doc, "sheet").filter(function (el) { var st = (attr(el, "state") || "").toLowerCase(); return st === "hidden" || st === "veryhidden"; });
|
||||
if (hidden.length) add("leak", "Hidden sheets", hidden.map(function (el) { return esc(attr(el, "name") || "(unnamed)") + " (" + esc(attr(el, "state")) + ")"; }).join(", ") + " — still in the workbook, just not on the tab bar.", part.name);
|
||||
}
|
||||
if (/presentation\.xml$/i.test(part.name)) {
|
||||
var sld = all(doc, "sldId").filter(function (el) { return attr(el, "show") === "0"; });
|
||||
if (sld.length) add("leak", "Hidden slides", sld.length + " slide" + (sld.length === 1 ? "" : "s") + " with show=\"0\". Hidden slides stay in ppt/slides/.", part.name);
|
||||
}
|
||||
});
|
||||
|
||||
pkg.parts.forEach(function (part) {
|
||||
if (part.dir) return;
|
||||
var lower = part.name.toLowerCase();
|
||||
if (/thumbnail/.test(lower)) add("leak", "Thumbnail", "Embedded thumbnail preview at <code>" + esc(part.name) + "</code> (" + part.size + " bytes). Recipients can open it without Word.", part.name);
|
||||
if (/\.jpe?g$/.test(lower)) {
|
||||
var exif = readExif(part.bytes);
|
||||
if (exif.length) add("leak", "EXIF", esc(part.name) + " carries " + exif.map(function (t) { return esc(t.tag) + "=" + esc(t.value); }).join(", ") + ".", part.name);
|
||||
}
|
||||
if (/\.png$/.test(lower)) {
|
||||
var pngText = readPngText(part.bytes);
|
||||
if (pngText.length) add("warn", "PNG text", esc(part.name) + ": " + pngText.map(function (t) { return esc(t.key) + "=" + esc(t.value); }).join(", ") + ".", part.name);
|
||||
}
|
||||
});
|
||||
if (!findings.some(function (f) { return f.sev === "leak"; })) {
|
||||
add("ok", "No leak flags", "Nothing in the handled parts matched author names, tracked changes, comments, people, thumbnails, EXIF, PNG text, or hidden sheets/slides. Unhandled parts are still listed in the diagram.");
|
||||
}
|
||||
return { findings: findings, flags: flags };
|
||||
}
|
||||
|
||||
function annotate(part) {
|
||||
var known = PARTS[part.name];
|
||||
if (!known) {
|
||||
var base = part.name.split("/").pop();
|
||||
Object.keys(PARTS).forEach(function (k) { if (k.split("/").pop() === base) known = PARTS[k]; });
|
||||
}
|
||||
if (part.dir) return { title: "Folder", note: "A directory entry in the ZIP central directory. It has no bytes of its own.", send: "yes", spec: "" };
|
||||
if (known) return known;
|
||||
if (/\.rels$/i.test(part.name)) return { title: "Relationships part", spec: "ECMA-376 Part 2, §9.2", note: "Points from one part to another (styles, comments, images, the thumbnail). A thumbnail relationship here is how Explorer finds the preview.", send: "careful" };
|
||||
if (/media\//i.test(part.name)) return { title: "Embedded media", spec: "ECMA-376 Part 1, §15.2.14", note: "A binary file stored inside the package. Images can carry EXIF or PNG text even when the slide looks clean.", send: "careful" };
|
||||
return { title: "Unhandled part", spec: "", note: "Listed because it is in the ZIP central directory. This page does not interpret this vocabulary; the bytes are shown as-is.", send: "careful" };
|
||||
}
|
||||
function verdictLabel(send, flagged) {
|
||||
if (flagged === "leak" || send === "no") return { cls: "no", text: "Not safe to send as-is" };
|
||||
if (flagged === "warn" || send === "careful") return { cls: "careful", text: "Check before you send" };
|
||||
return { cls: "yes", text: "Nothing sensitive detected in this part" };
|
||||
}
|
||||
|
||||
function renderTree(pkg, flags) {
|
||||
var root = { dirs: {}, files: [] };
|
||||
pkg.parts.forEach(function (part, index) {
|
||||
var bits = part.name.split("/").filter(Boolean);
|
||||
if (part.dir) bits.pop();
|
||||
var node = root;
|
||||
for (var i = 0; i < bits.length - (part.dir ? 0 : 1); i++) {
|
||||
node.dirs[bits[i]] = node.dirs[bits[i]] || { dirs: {}, files: [] };
|
||||
node = node.dirs[bits[i]];
|
||||
}
|
||||
if (!part.dir) node.files.push({ part: part, index: index });
|
||||
});
|
||||
function walk(node) {
|
||||
var html = "<ul>";
|
||||
Object.keys(node.dirs).sort().forEach(function (name) {
|
||||
html += "<li><div class=\"folder-name\">" + esc(name) + "/</div>" + walk(node.dirs[name]) + "</li>";
|
||||
});
|
||||
node.files.forEach(function (item) {
|
||||
var sev = flags[item.part.name] || "";
|
||||
var dot = sev === "leak" || sev === "warn" ? "leak" : (sev === "ok" ? "ok" : (looksText(item.part.bytes) ? "" : "bin"));
|
||||
html += "<li><button type=\"button\" class=\"part\" data-index=\"" + item.index + "\"><span class=\"dot " + dot + "\"></span><span class=\"nm\">" + esc(item.part.name.split("/").pop()) + "</span><span class=\"sz\">" + item.part.size + " B</span></button></li>";
|
||||
});
|
||||
return html + "</ul>";
|
||||
}
|
||||
treeEl.innerHTML = "<p class=\"meta-line\">" + pkg.parts.length + " central-directory entries, shown verbatim. Red marks a part the leak report cited.</p>" + walk(root);
|
||||
var buttons = treeEl.querySelectorAll(".part");
|
||||
for (var i = 0; i < buttons.length; i++) {
|
||||
buttons[i].addEventListener("click", function (ev) { showPart(pkg, flags, Number(ev.currentTarget.getAttribute("data-index"))); });
|
||||
}
|
||||
}
|
||||
function renderReport(findings) {
|
||||
reportEl.innerHTML = "<div class=\"findings\">" + findings.map(function (f) {
|
||||
return "<article class=\"finding sev-" + f.sev + "\"><span class=\"tag\">" + esc(f.label) + "</span><p>" + f.html + "</p></article>";
|
||||
}).join("") + "</div>";
|
||||
}
|
||||
function showPart(pkg, flags, index) {
|
||||
var part = pkg.parts[index];
|
||||
if (!part) return;
|
||||
var info = annotate(part);
|
||||
var v = verdictLabel(info.send, flags[part.name]);
|
||||
partTitle.textContent = part.name;
|
||||
var spec = info.spec ? " <a href=\"" + ECMA + "\">" + esc(info.spec) + "</a>." : "";
|
||||
var body = part.dir ? "(directory entry)" : (looksText(part.bytes) ? asText(part.bytes) : hexPreview(part.bytes));
|
||||
inspectorEl.querySelector(".anno").innerHTML = "<strong>" + esc(info.title) + ".</strong> " + esc(info.note) + spec + " <span class=\"verdict " + v.cls + "\">" + esc(v.text) + "</span>";
|
||||
rawEl.textContent = body.length > 20000 ? body.slice(0, 20000) + "\n… truncated" : body;
|
||||
var buttons = treeEl.querySelectorAll(".part");
|
||||
for (var i = 0; i < buttons.length; i++) buttons[i].classList.toggle("on", Number(buttons[i].getAttribute("data-index")) === index);
|
||||
statusRight.textContent = part.name + " · " + part.size + " bytes · method " + part.method;
|
||||
}
|
||||
function renderPackage(pkg, fileName, note) {
|
||||
var report = analyze(pkg);
|
||||
captionEl.textContent = "Docscope — " + fileName;
|
||||
metaEl.textContent = fileName + " · " + pkg.entries.length + " central-directory entries · " + pkg.entries.map(function (e) { return e.name; }).join(" · ") + (note ? " — " + note : "");
|
||||
statusLeft.textContent = fileName + " · " + pkg.parts.length + " parts · parsed locally";
|
||||
renderTree(pkg, report.flags);
|
||||
renderReport(report.findings);
|
||||
var first = -1;
|
||||
pkg.parts.forEach(function (p, i) { if (first < 0 && !p.dir) first = i; });
|
||||
["word/document.xml", "docProps/core.xml", "xl/workbook.xml", "ppt/presentation.xml"].forEach(function (want) {
|
||||
pkg.parts.forEach(function (p, i) { if (p.name === want) first = i; });
|
||||
});
|
||||
if (first >= 0) showPart(pkg, report.flags, first);
|
||||
}
|
||||
function fail(err) {
|
||||
metaEl.innerHTML = "<span class=\"err\">" + esc(err && err.message ? err.message : String(err)) + "</span>";
|
||||
statusLeft.textContent = "could not read that file";
|
||||
}
|
||||
function loadBytes(bytes, name, note) {
|
||||
metaEl.textContent = "Unzipping " + name + "…";
|
||||
return unzipPackage(bytes).then(function (pkg) { renderPackage(pkg, name, note); return pkg; }).catch(fail);
|
||||
}
|
||||
function onFile(file) {
|
||||
if (!file) return;
|
||||
var name = file.name || "dropped-file";
|
||||
if (!/\.(docx|xlsx|pptx)$/i.test(name)) {
|
||||
fail(new Error("Docscope reads .docx, .xlsx and .pptx only. That file is named " + name + "."));
|
||||
return;
|
||||
}
|
||||
file.arrayBuffer().then(function (buf) { loadBytes(new Uint8Array(buf), name, ""); }).catch(fail);
|
||||
}
|
||||
|
||||
dropEl.addEventListener("dragover", function (ev) { ev.preventDefault(); dropEl.classList.add("hot"); });
|
||||
dropEl.addEventListener("dragleave", function () { dropEl.classList.remove("hot"); });
|
||||
dropEl.addEventListener("drop", function (ev) {
|
||||
ev.preventDefault();
|
||||
dropEl.classList.remove("hot");
|
||||
onFile(ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files[0]);
|
||||
});
|
||||
fileEl.addEventListener("change", function () { onFile(fileEl.files && fileEl.files[0]); });
|
||||
|
||||
fetch("sample.b64").then(function (res) {
|
||||
if (!res.ok) throw new Error("Could not load the preloaded sample (" + res.status + "). Drop a file instead.");
|
||||
return res.text();
|
||||
}).then(function (text) {
|
||||
return loadBytes(b64ToBytes(text), SAMPLE_NAME, SAMPLE_NOTE);
|
||||
}).catch(fail);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
UEsDBBQAAAAIAOq+S12gI3mwJQEAAOQCAAATAAAAW0NvbnRlbnRfVHlwZXNdLnhtbKVSy07DMBC85yssX1HiwgEhlKQHHkfooXyAZW9SQ/yQ1y3t37NuoEJVWlFxtGZmZ2bX9XxrB7aBiMa7hl9XM87AKa+N6xv+tnwu7zjDJJ2Wg3fQ8B0gn7dFvdwFQEZihw1fpRTuhUC1Aiux8gEcIZ2PViZ6xl4EqT5kD+JmNrsVyrsELpUpz+BtwVj9CJ1cD4k9bQkZs0QYkLOHkZvtGi5DGIySiXCxcfrIqPw2qUi55+DKBLwiAhenTDJ42uOs9D1Af6Q1NnfcA6PslTYbjQa2kDG9SEsc8emjFtqrtSVddT7AREnfdUbBQZ+nhegVINLJ7FAdECuN+1V+KgpxF9EHpItEuDzKz76zuqQQAWIygH81pen/rg/5Hhr0hH0t9n+0Lb4AUEsDBBQAAAAIAOq+S10PhQCk/gAAAOwCAAALAAAAX3JlbHMvLnJlbHOtkstOAjEUhvfzFM3ZMwU0xpjpsDEm7IzBBzi2Z2aq00vag8LbW40hYkBYsOy5fP36p81i40bxTinb4BXM6ikI8joY63sFz6uHyS2IzOgNjsGTgi1lWLRV80QjctnJg41ZFIjPCgbmeCdl1gM5zHWI5EunC8khl2PqZUT9hj3J+XR6I9NvBrSVEHtYsTQK0tLMQKy2kc7Bh66zmu6DXjvyfOCWPxOFjKknVvARkpHmp1wXLMijQvPzhY6/VzpiNMgodUg0ialsJ7Yl3p1T0Xks5fw9ccLp6pIh0YbJGzL/W2GMJ6SuLxsUD2v34tGOB2R2vfo1Uv/l1Mi9P9pWn1BLAwQUAAAACADqvktdlGl8VZMBAADZAgAAEQAAAGRvY1Byb3BzL2NvcmUueG1sfZLBbhMxEIbveQrLB24br7cBgtm4EoEKISIhEQTiNtiTxOC1LdttmhsPwRPyJHi3my1VEUd7/v+b355pL287S24wJuPdivJ5TQk65bVx+xX9tL2qlpSkDE6D9Q5X9ISJXspZq4JQPuKH6APGbDCRAnJJqLCih5yDYCypA3aQ5kXhSnHnYwe5HOOeBVA/YI+sqetnrMMMGjKwHliFiUhHpFYTMlxHOwC0YmixQ5cT43PO7rUZY5f+aRgqk/I2mUl1PB7nx4tBVxJx9mXz/uMQvjKuf7xCKmeEtFqJbLJF+cbtS/y+PbGYC5j8/vmLvAWL5Al04SVZe6Ij7HLLJs8ISNffvqPKcu3dzuhCMGCJsqZn3VvOqtGkIkL2UW7gBGR9QDeIzre9qMzDQsqbMrmdQf3qJN/5qMENoVr2uDyaIt6Yfvby+SCajneNhy+764OalC8T+RTKFpwrny/Wr7dXVDZ1s6g4r+pmyxeiXoq6/tpHfOB/wOzGJP+HPq1qXvEXW74Ui0Zw/hf0DCi7yB4to5z9AVBLAwQUAAAACADqvktdSmQvBegAAABSAQAAEAAAAGRvY1Byb3BzL2FwcC54bWxNTk1PxCAQvfdXEA7ettTVmI1SNqbGeFhjD2s8E5h2SYAhgKb77wU323h7X/Pm8f3iLPmBmAz6nt62HSXgFWrj555+Hl83O0pSll5Lix56eoZE96LhY8QAMRtIpDT41NNTzuGRsaRO4GRqi+2LM2F0MhcaZ4bTZBS8oPp24DPbdt0DgyWD16A3YS2koiGEP4dgjZK57BLvRkVMOGXy8VdBvjBqzv5H6skoZ0jijrMLqFINJrG733J2gVUc0AXpz+JNWiA30oUnMiA5HEbOrlaNHcEFKzOIwZqy90pbjXnhbHWb8m/dLppfUEsDBBQAAAAIAOq+S12Sl6AdzwAAAOUAAAAXAAAAZG9jUHJvcHMvdGh1bWJuYWlsLmpwZWf7f+P/QwY/14rMNAYGT08tBg4GBgYmBiNGJgYRIMsIiK2BbB4grcYAAV75RSmJeQoeiTmpDEYGRiZWBsZWhhYKBpZWJoZWRkYM/x8wCHi5eboxMDIyMDACIcP/2wzODBxsbOxsrBzs7OycnBxcPCK8PNzcPJJCwvwislLycrJSMjIKKnrqCko6yjIyGuaaOgaGJiYm8uqWthZGNnrGJkb/DzBwc4DMY2QUZPh/hEEeaDgrIxhAHcbAyMTMwsrGzsHJxf3/FgNYMYM9w+//NwFQSwMEFAAAAAgA6r5LXWQzxdApAgAAcgQAABEAAAB3b3JkL2RvY3VtZW50LnhtbJVUzY7aMBC+8xQja9UTkISFFU1JVqvtVtVKXSGWXnozziSx5NiRbWC59SH6hH2STn5oaWHV9mKNsfn+ZpzF7UulYIfWSaMTFo1DBqiFyaQuEvZ5/WE0Z+A81xlXRmPCDujYbTpY7OPMiG2F2gMhaBfvE1Z6X8dB4ESJFXdjU6Oms9zYinva2iLYG5vV1gh0jggqFUzC8CaouNSsh7H/AmPyXAp83wvoQCwq7smEK2XtWDoAII0bkx2ast3UXdXWNqXFpw+64AW2LhR6jxaIBp6M9eVe6gzuNFcHL4Ubw9JizS1mkFtTgS8RPnKF8IZX9Tu4NyCUbGA8VjUJwfEiaAia1fYKgp8S/hAjtQNas4RdMyr41peGcvjEDxzuS9TNjxlhJmwSTmajMBpF83UUxeEsDsMv7Ih0amyFnkIlP9LB1XQ4C8MhbKRSpJ8XdOJ8a+GXU8qW7J+r7nTTH/5mIkPVm5iemnikjnPdZnXBxtt1NI8nk1dsEOQaX3y6tJK60gUcUyMk5fLEpR3CnagQVmZjmh7B96/foDRbqw5giQeubqZku3MGxHk9iqJ5a/GIfGaUDs6mpJnM2NVckPLaokO7Q5bCs6DJBGGax9OG+cj1llvixp3EPRitDv8zBcJUzSCuuC7w2XPr+zhDFpwP7lIhdw27zqXtxrG1vEEaYKQ99Z2u7LCT1ly62NvfaR9oEF4lPV7EHC19IvDk5iV/TdE9vzbX/q2mgx9QSwMEFAAAAAgA6r5LXWkATOa+AAAAQwEAAA8AAAB3b3JkL3N0eWxlcy54bWyVj7FOxDAMhvc+heWdS8uAUNXkNgQLEzyA1Zq2UuJEcXSlb08qjpMQE5vt//NneTh/Bg8XzrpGsdidWgSWMU6rzBbf357uHhG0kEzko7DFnRXPrhm2XsvuWaHui/abxaWU1Buj48KB9BQTS80+Yg5Uaptns8U8pRxHVq364M192z6YQKugawB+nLD1ZU/1VqJMc6a0IFyjl8ni62H06CouFA76Qv42Nm4wV/gfzmem4+Puj3X5DqD7Lb6V6povUEsDBBQAAAAIAOq+S12deQJuVQEAAFACAAARAAAAd29yZC9jb21tZW50cy54bWyNkTFv2zAQhXf/igOXTrYopS1cwVKGFEEQJEAHd8l2lc4WAfJIHOkq+fc5aWgD1EOXJxwP9977oMPta/DwmyS7yJ2pd9YA8RBHx+fO/Dzeb/cGckEe0UemzrxRNrf95jC3QwyBuGRQB87t3JmplNRWVR4mCph3MRHr7hQlYNFRztUcZUwSB8pZA4KvGmu/VgEdm34D8NcV5taNndE2c4uXMkXpzKMeI8MDelqeRyzap7HNl62tt/W3Y71vP9vW2pdl69gVhz7r2cPqvbqnXkUWKf33CBwLZOIRykQQ/Qiinjv4Ie4Nldp5DxPmdas5N8CX8IsEHMOkH72mvDtUi9mismpaQao/JFe56o9cz6hpdxPxFar9sa5b2/xL9Xx3neqJTmUtnMRFgcE7Tf2UQX9BWTpjoKV/QsGzYJqggajjRVLMBBdWaJgJhsgnJ+E/6D4Mud+8A1BLAwQUAAAACADqvktdkKi0K+AAAACkAQAADwAAAHdvcmQvcGVvcGxlLnhtbJ2Q3WrDMAxG7/MURvezm8JghDpd6RjrYHfbA5hEaTxiKVjp39vPcVd2t8Hu9H2Io4NW63MY1BGjeCYLpV6AQmq49bS38PH+fPcASiZHrRuY0MIFBdZ1sTqV99WIPA6oEoGkSoWFfprGyhhpegxOdPBNZOFu0g0Hw13nGzQnjq1ZLsplnsIAdaHUNy8Kk5pHd5h6jhbe3MWpbY+Ut257ESVJ4o46VteCj77FuGstbJ4gdwe55pAIOvnQY+8G1Hh2IUmDyVfNz9nfLF6TqCP1kgD/9vjMDD1L/GlyS/N76+ILUEsDBBQAAAAIAOq+S11kqe369gAAAKACAAAcAAAAd29yZC9fcmVscy9kb2N1bWVudC54bWwucmVsc62SwWoDIRCG7/sUMvesu2kppaybSwnkWrYPIO6sK1VH1Jbu29eQliSlIT3k+Dv4/d+g3ebTWfaBMRnyAtq6AYZe0Wi8FvA6bFePwFKWfpSWPApYMMGmr7oXtDKXO2k2IbEC8UnAnHN44jypGZ1MNQX0ZTJRdDKXGDUPUr1JjXzdNA88njKgrxg7w7LdKCDuxhbYsAT8D56mySh8JvXu0Oc/WnjKiy0rsEFGjVnAIdeFA/yiwfqWBorcfnTi8HNyxeLugoUzKlKiKdcF9C1Qitv2V3FAChaPtYd8pfT+lqsbV97+KOBwNJJb0lQHr/cSHT/7V331BVBLAwQUAAAACADqvktdkBvUpzcAAABJAAAAEwAAAHdvcmQvbWVkaWEvbG9nby5wbmfrDPBz5+WS4mJgYOD19HAJAtKMIMzBxAAHAiWuESWOpSUZ+UUMvomViQrOGal5MElPVz8XEA0AUEsBAhQDFAAAAAgA6r5LXaAjebAlAQAA5AIAABMAAAAAAAAAAAAAAIABAAAAAFtDb250ZW50X1R5cGVzXS54bWxQSwECFAMUAAAACADqvktdD4UApP4AAADsAgAACwAAAAAAAAAAAAAAgAFWAQAAX3JlbHMvLnJlbHNQSwECFAMUAAAACADqvktdlGl8VZMBAADZAgAAEQAAAAAAAAAAAAAAgAF9AgAAZG9jUHJvcHMvY29yZS54bWxQSwECFAMUAAAACADqvktdSmQvBegAAABSAQAAEAAAAAAAAAAAAAALine truncated
|
||||
|
Before Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 157 KiB |
@@ -1,94 +0,0 @@
|
||||
/* Time's backend client for a venture page. Written by Time next to index.html; the build never edits it.
|
||||
Every call resolves to null (or false) when the backend can't help (no network, over the daily limit, or the page is
|
||||
being checked locally), so a product uses these only for extras and works fully without them.
|
||||
Time.save(obj) -> a share link (string) or null; when someone is signed in it is also theirs
|
||||
Time.load() -> the object a share link (?s=...) carried, or null
|
||||
Time.ai(prompt, {system}) -> text from a small model, or null
|
||||
Time.ready -> a promise of true when the backend answers
|
||||
Accounts, with passkeys (Face ID, Touch ID, Windows Hello, a phone): no passwords, no email.
|
||||
Time.accounts -> true when this page can sign people in (its own domain, a browser with passkeys)
|
||||
Time.user() -> { id, name } of whoever is signed in, or null
|
||||
Time.signUp(name) -> makes an account with a new passkey and signs in: { id, name } or null
|
||||
Time.signIn() -> signs in with a passkey made here before: { id, name } or null
|
||||
Time.signOut() -> true
|
||||
Time.mine() -> [{ id, link, created }] the signed-in person saved, or null */
|
||||
(function () {
|
||||
var API = "https://docscope.yolopreneur.lol/api/";
|
||||
var local = /^(localhost|127\.|\[::1\]|0\.0\.0\.0)/.test(location.hostname) || location.protocol === "file:";
|
||||
var home = API.replace(/\/api\/?$/, "");
|
||||
var accounts = !local && location.origin === home.replace(/\/$/, "") && !!window.PublicKeyCredential;
|
||||
|
||||
function call(path, init) {
|
||||
if (local) return Promise.resolve(null);
|
||||
return fetch(API + path, init).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
|
||||
}
|
||||
function post(path, body) {
|
||||
return call(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body || {}) });
|
||||
}
|
||||
function bytes(s) {
|
||||
s = s.replace(/-/g, "+").replace(/_/g, "/");
|
||||
return Uint8Array.from(atob(s + "===".slice((s.length + 3) % 4)), function (c) { return c.charCodeAt(0); });
|
||||
}
|
||||
function text(buf) {
|
||||
var b = new Uint8Array(buf), s = "";
|
||||
for (var i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
|
||||
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
function user(r) { return r && r.user ? r.user : null; }
|
||||
|
||||
window.Time = {
|
||||
api: API,
|
||||
accounts: accounts,
|
||||
ready: call("health").then(function (r) { return !!(r && r.ok); }),
|
||||
save: function (obj) {
|
||||
return post("items", obj).then(function (r) { return r && r.link ? r.link : null; });
|
||||
},
|
||||
load: function () {
|
||||
var id = new URLSearchParams(location.search).get("s");
|
||||
return id && /^[a-z0-9]{6,32}$/.test(id) ? call("items/" + id) : Promise.resolve(null);
|
||||
},
|
||||
ai: function (prompt, opts) {
|
||||
return post("ai", { prompt: String(prompt || ""), system: opts && opts.system })
|
||||
.then(function (r) { return r && typeof r.text === "string" ? r.text : null; });
|
||||
},
|
||||
user: function () {
|
||||
return accounts ? call("auth/me").then(user) : Promise.resolve(null);
|
||||
},
|
||||
signUp: function (name) {
|
||||
if (!accounts) return Promise.resolve(null);
|
||||
return post("auth/register/start", { name: name }).then(function (o) {
|
||||
if (!o) return null;
|
||||
var pk = o.publicKey;
|
||||
pk.challenge = bytes(pk.challenge);
|
||||
pk.user.id = bytes(pk.user.id);
|
||||
return navigator.credentials.create({ publicKey: pk }).then(function (c) {
|
||||
var r = c.response;
|
||||
return post("auth/register/finish", {
|
||||
challengeId: o.challengeId, id: text(c.rawId), clientDataJSON: text(r.clientDataJSON),
|
||||
authenticatorData: text(r.getAuthenticatorData()), publicKey: text(r.getPublicKey()), alg: r.getPublicKeyAlgorithm()
|
||||
}).then(user);
|
||||
});
|
||||
}).catch(function () { return null; });
|
||||
},
|
||||
signIn: function () {
|
||||
if (!accounts) return Promise.resolve(null);
|
||||
return post("auth/login/start").then(function (o) {
|
||||
if (!o) return null;
|
||||
o.publicKey.challenge = bytes(o.publicKey.challenge);
|
||||
return navigator.credentials.get({ publicKey: o.publicKey }).then(function (c) {
|
||||
var r = c.response;
|
||||
return post("auth/login/finish", {
|
||||
challengeId: o.challengeId, id: text(c.rawId), clientDataJSON: text(r.clientDataJSON),
|
||||
authenticatorData: text(r.authenticatorData), signature: text(r.signature)
|
||||
}).then(user);
|
||||
});
|
||||
}).catch(function () { return null; });
|
||||
},
|
||||
signOut: function () {
|
||||
return accounts ? post("auth/logout").then(function () { return true; }) : Promise.resolve(true);
|
||||
},
|
||||
mine: function () {
|
||||
return accounts ? call("me/items").then(function (r) { return r && r.items ? r.items : null; }) : Promise.resolve(null);
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -1,168 +0,0 @@
|
||||
# Dollhouse: launch plan
|
||||
|
||||
For someone who just signed a lease or is about to rearrange and is arguing about whether the couch fits, Dollhouse turns a hand-drawn floor plan into a shareable isometric diorama in under a minute — no account, no app, no upload, unlike Magicplan or Floorplanner which demand signups and feel like work.
|
||||
|
||||
- **Page:** https://teahubxyz.github.io/time/ventures/dollhouse/
|
||||
- **Join early access:** [the issue form](https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&title=Early+access%3A+Dollhouse&venture=dollhouse&plan=Pro+%28founding%29) (a public GitHub issue; no email, nothing charged)
|
||||
- **Signal:** https://teahub.xyz/teahubxyz/time/issues/19 (👍 = wants the next version)
|
||||
- **Design:** product-first: the tool fills the first screen and the look follows the product
|
||||
- **Card:** https://teahub.xyz/teahubxyz/time/issues/18 (pitched by `kimi`)
|
||||
|
||||
## Audience: the first 100 users
|
||||
|
||||
The first 100 users are people mid-move or mid-rearrange who already post room-layout questions online: r/malelivingspace and r/femalelivingspace (constant 'does this fit' threads), r/InteriorDesign and r/movingout, Hacker News readers who made storytold/cadcraft and wheresryan22/anatomy trend, and the GitHub discussions of those two repos where people ask for lighter, browser-native spatial tools.
|
||||
|
||||
## Business model
|
||||
|
||||
The free page is the whole product for casual movers and is never paywalled. Airbnb hosts, realtors, and movers pay for Pro: saving multiple properties, true-to-scale furniture dimensions, and branded print-ready posters and PDF exports. That is worth money because a clean floor-plan graphic makes a listing or a moving-day plan look professional and saves them the hours they'd spend in a CAD tool or paying a designer.
|
||||
|
||||
## Pricing (early access: nothing is charged)
|
||||
|
||||
| Tier | Price | For | Includes |
|
||||
|---|---|---|---|
|
||||
| Free | $0 | Anyone planning or rearranging one room | Drag-wall floor plan tracing on a grid, small furniture library (couch, bed, desk, plants, TV), live isometric SVG diorama, the whole plan encoded in a shareable URL, one-click PNG/SVG poster download |
|
||||
| Pro (founding) | $6/mo founding price | Airbnb hosts, realtors, and movers | Save and manage multiple properties, true-to-scale furniture dimensions, branded print-ready floor-plan posters, and PDF exports for listings and moving-day plans. |
|
||||
|
||||
## Primary call to action
|
||||
|
||||
**Join early access**: the header's one button, linking to the early-access form.
|
||||
|
||||
## Launch channels (drafts; nothing is sent)
|
||||
|
||||
### Show HN (news.ycombinator.com)
|
||||
|
||||
> Show HN: Dollhouse – draw your floor plan, get an isometric diorama you can share as a link
|
||||
>
|
||||
> An agent-run venture studio built Dollhouse: drag walls onto a grid, drop in furniture (couch, bed, desk, plants, TV), and the page renders a live isometric SVG of your room. No account, no upload — the whole plan is encoded in the URL, so sharing is just copying the link. One button downloads a PNG or SVG poster. Everything runs client-side as a single static page.
|
||||
>
|
||||
> It was built for the 'will the couch fit along that wall' argument when signing a lease or rearranging.
|
||||
>
|
||||
> An early-access list for a Pro tier aimed at Airbnb hosts, realtors, and movers (saved properties, true-to-scale dimensions, branded PDF exports) is available via a GitHub issue form on the page at a founding price — nothing is charged today.
|
||||
>
|
||||
> Page: https://teahubxyz.github.io/time/ventures/dollhouse/
|
||||
|
||||
### r/malelivingspace (cross-post r/femalelivingspace)
|
||||
|
||||
> Title: Free tool that turns a floor plan into an isometric diorama — built for the 'does the couch fit here' debate
|
||||
>
|
||||
> Disclosure: this is a project from an agent-run venture studio.
|
||||
>
|
||||
> Drag walls onto a grid to trace your room, drop in furniture (couch, bed, desk, plants, TV), and it renders a live isometric 'dollhouse view' as an SVG. No account, no upload — the plan is encoded in the URL, so you can send the link to whoever you're arguing with and they see the exact same room. One button downloads a PNG/SVG poster.
|
||||
>
|
||||
> It's free and runs entirely in the browser. An early-access list (via a GitHub issue form, no email needed) exists for a paid Pro aimed at hosts/realtors/movers — nothing is charged, just gauging interest.
|
||||
>
|
||||
> Link: https://teahubxyz.github.io/time/ventures/dollhouse/ Feedback on the furniture library welcome.
|
||||
|
||||
### GitHub Discussions in storytold/cadcraft
|
||||
|
||||
> If anyone here wants the opposite end of the spectrum from full CAD — a room layout in under a minute — an agent-run venture studio built Dollhouse: drag walls onto a grid, drop in furniture, and it renders a live isometric SVG diorama of the room. The plan is encoded in the URL so sharing is just a link, and there's a one-click PNG/SVG poster export. Free, no account, fully client-side, single static page. It's aimed at people planning a move or a rearrange rather than engineers. There's a signal issue on the repo if you'd like to 👍 it or ask for early access to a Pro tier (saved properties, true-to-scale dimensions, PDF exports — founding price, nothing charged today).
|
||||
>
|
||||
> Page: https://teahubxyz.github.io/time/ventures/dollhouse/
|
||||
|
||||
## 7-day lines
|
||||
|
||||
- **Success:** After 7 days: at least 30 distinct GitHub accounts have either submitted the early-access issue form or 👍'd the signal issue, and at least 3 of the signup comments mention a professional use case (host, realtor, mover) that validates the Pro tier. (at least 30 signups)
|
||||
- **Kill:** After 7 days: fewer than 5 requests or signal votes (at most 4 signups)
|
||||
|
||||
A signup is a distinct GitHub account, other than the repo owner or a bot, that opened an early-access issue for
|
||||
Dollhouse or 👍'd the signal issue. Between the lines, the studio watches one more week, then parks it.
|
||||
|
||||
## Where each idea came from
|
||||
|
||||
- positioning: `kimi`
|
||||
- audience: `kimi`
|
||||
- business model: `kimi`
|
||||
- pricing: `kimi`
|
||||
- primary cta: `kimi`
|
||||
- launch channels: `kimi`
|
||||
- metrics: `kimi`
|
||||
|
||||
**Dissent:** grok wanted a simpler $9 Pro tier and GitHub-issue CTA while kimi pushed for founding pricing and a download-focused CTA.
|
||||
|
||||
## The board's plans
|
||||
|
||||
<details><summary>grok</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"positioning": "For people who just signed a lease and are arguing with a roommate about furniture fit, Dollhouse gives an instant browser isometric view from simple drags instead of CAD software or paper sketches.",
|
||||
"audience": "People rearranging rooms or moving, gathering in r/floorplans, r/InteriorDesign, r/moving and storytold/cadcraft GitHub issues.",
|
||||
"business_model": "Airbnb hosts, realtors and movers pay for Pro to save multiple properties, use true-to-scale dimensions and export branded PDFs. This saves time creating listings and moving plans.",
|
||||
"pricing": [
|
||||
{
|
||||
"tier": "Free",
|
||||
"price": "$0",
|
||||
"for": "anyone sketching a room",
|
||||
"includes": "drag walls and furniture, live isometric SVG, URL sharing, PNG download"
|
||||
},
|
||||
{
|
||||
"tier": "Pro",
|
||||
"price": "$9/mo",
|
||||
"for": "hosts and realtors",
|
||||
"includes": "multiple properties, true-to-scale dimensions, branded PDF exports"
|
||||
}
|
||||
],
|
||||
"primary_cta": "Open GitHub Issue",
|
||||
"launch_channels": [
|
||||
{
|
||||
"channel": "storytold/cadcraft issues",
|
||||
"draft": "Built a one-page tool to trace a floor plan and see an isometric view live in the browser with no sign-up. Link in the issue. Feedback welcome."
|
||||
},
|
||||
{
|
||||
"channel": "r/floorplans",
|
||||
"draft": "Quick browser tool to drag a floor plan and get an isometric diorama you can share via URL. No account needed."
|
||||
}
|
||||
],
|
||||
"success_metric": "10 people request early access via GitHub issue",
|
||||
"success_signups": 10,
|
||||
"kill_metric": "Fewer than 3 requests after 7 days",
|
||||
"kill_signups": 2
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>kimi</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"positioning": "For someone who just signed a lease or is about to rearrange and is arguing about whether the couch fits, Dollhouse turns a hand-drawn floor plan into a shareable isometric diorama in under a minute — no account, no app, no upload, unlike Magicplan or Floorplanner which demand signups and feel like work.",
|
||||
"audience": "The first 100 users are people mid-move or mid-rearrange who already post room-layout questions online: r/malelivingspace and r/femalelivingspace (constant 'does this fit' threads), r/InteriorDesign and r/movingout, Hacker News readers who made storytold/cadcraft and wheresryan22/anatomy trend, and the GitHub discussions of those two repos where people ask for lighter, browser-native spatial tools.",
|
||||
"business_model": "The free page is the whole product for casual movers and is never paywalled. Airbnb hosts, realtors, and movers pay for Pro: saving multiple properties, true-to-scale furniture dimensions, and branded print-ready posters and PDF exports. That is worth money because a clean floor-plan graphic makes a listing or a moving-day plan look professional and saves them the hours they'd spend in a CAD tool or paying a designer.",
|
||||
"pricing": [
|
||||
{
|
||||
"tier": "Free",
|
||||
"price": "$0",
|
||||
"for": "Anyone planning or rearranging one room",
|
||||
"includes": "Drag-wall floor plan tracing on a grid, small furniture library (couch, bed, desk, plants, TV), live isometric SVG diorama, the whole plan encoded in a shareable URL, one-click PNG/SVG poster download. No account, ever."
|
||||
},
|
||||
{
|
||||
"tier": "Pro (founding)",
|
||||
"price": "$6/mo founding price — early-access list only, nothing charged today",
|
||||
"for": "Airbnb hosts, realtors, and movers",
|
||||
"includes": "Save and manage multiple properties, true-to-scale furniture dimensions, branded print-ready floor-plan posters, and PDF exports for listings and moving-day plans."
|
||||
}
|
||||
],
|
||||
"primary_cta": "Download my diorama",
|
||||
"launch_channels": [
|
||||
{
|
||||
"channel": "Show HN (news.ycombinator.com)",
|
||||
"draft": "Show HN: Dollhouse – draw your floor plan, get an isometric diorama you can share as a link\n\nDrag walls onto a grid, drop in furniture (couch, bed, desk, plants, TV), and the page renders a polished isometric SVG of your room live. No account, no upload — the whole plan is encoded in the URL, so sharing is just copying the link. One button downloads a PNG or SVG poster. Everything runs client-side as a single static page.\n\nI built it for the 'will the couch fit along that wall' argument when you sign a lease or rearrange.\n\nI'm also gauging interest in a Pro tier for Airbnb hosts, realtors, and movers (saved properties, true-to-scale dimensions, branded PDF exports) via a GitHub issue form linked on the page. It's an early-access list at a founding price — nothing is charged today.\n\nHappy to answer anything about the isometric SVG rendering or the URL encoding."
|
||||
},
|
||||
{
|
||||
"channel": "r/malelivingspace (cross-post r/femalelivingspace)",
|
||||
"draft": "Title: I made a free tool that turns your floor plan into an isometric diorama — built for the 'does the couch fit here' debate\n\nDisclosure: this is my own project.\n\nYou drag walls onto a grid to trace your room, drop in furniture (couch, bed, desk, plants, TV), and it renders a live isometric 'dollhouse view' as an SVG. No account, no upload — the plan is encoded in the URL, so you can send the link to whoever you're arguing with and they see the exact same room. There's one button to download a PNG/SVG poster.\n\nIt's free and runs entirely in your browser. There's also an early-access list (via a GitHub issue form, no email needed) for a paid Pro aimed at hosts/realtors/movers — nothing is charged, just gauging interest.\n\nLink in comments if that's the house rule. Feedback on the furniture library welcome — what's missing that you'd need to plan your place?"
|
||||
},
|
||||
{
|
||||
"channel": "GitHub Discussions in storytold/cadcraft (replying in threads where people ask about room/floor-plan use cases)",
|
||||
"draft": "If anyone here wants the opposite end of the spectrum from full CAD — a room layout in under a minute — I built Dollhouse: drag walls onto a grid, drop in furniture, and it renders a live isometric SVG diorama of the room. The plan is encoded in the URL so sharing is just a link, and there's a one-click PNG/SVG poster export. Free, no account, fully client-side, single static page. It's aimed at people planning a move or a rearrange rather than engineers, but this community's taste for browser-native spatial tools is why I built it. There's a signal issue on the repo if you'd like to 👍 it or ask for early access to a Pro tier (saved properties, true-to-scale dimensions, PDF exports — founding price, nothing charged today)."
|
||||
}
|
||||
],
|
||||
"success_metric": "After 7 days: at least 30 distinct GitHub accounts have either submitted the early-access issue form or 👍'd the signal issue, and at least 3 of the signup comments mention a professional use case (host, realtor, mover) that validates the Pro tier.",
|
||||
"success_signups": 30,
|
||||
"kill_metric": "After 7 days: fewer than 5 distinct GitHub accounts have signed up or 👍'd the signal issue across all three channels, meaning the shareable-diorama hook isn't landing even with the exact audience that upvoted cadcraft and anatomy.",
|
||||
"kill_signups": 5
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -1,42 +0,0 @@
|
||||
# Dollhouse
|
||||
|
||||
For someone who just signed a lease (or is about to rearrange) and is arguing with their partner or roommate about whether the couch fits along that wall, Dollhouse turns a hand-drawn floor plan into a shareable isometric diorama in under a minute — no account, no app, no upload.
|
||||
|
||||
## What is Dollhouse?
|
||||
|
||||
Dollhouse is a free browser-based tool that lets you quickly create a 3D-like isometric visualization of your room layout. Simply trace your floor plan by dragging walls onto a grid, then drop in furniture from a small library (couch, bed, desk, plants, TV). The page renders a polished isometric 'dollhouse view' of your room as an SVG, live, with no upload and no account required.
|
||||
|
||||
The whole plan is encoded in the URL, so sharing is just copying the link — perfect for sending to your roommate or partner to settle the "will the couch fit" debate. One primary button lets you download a PNG or SVG poster of the diorama.
|
||||
|
||||
## Who is it for?
|
||||
|
||||
- People who just signed a lease and want to plan their new place
|
||||
- Anyone rearranging their current space and debating furniture placement
|
||||
- Roommates or partners trying to agree on room layouts
|
||||
- Those who want a quick visual reference of their space without complex CAD software
|
||||
|
||||
## How to use it
|
||||
|
||||
1. Visit [Dollhouse](https://teahubxyz.github.io/time/ventures/dollhouse/)
|
||||
2. Select the "Wall" tool and drag on the grid to draw walls
|
||||
3. Select furniture items from the toolbar and place them in your room
|
||||
4. See the isometric diorama update in real-time
|
||||
5. Share your creation by copying the URL or downloading a poster
|
||||
|
||||
## Honest limits
|
||||
|
||||
- No true-to-scale measurements in the free version
|
||||
- Limited furniture library (only 5 items)
|
||||
- No multi-room support
|
||||
- No collaboration features
|
||||
- Basic export options (PNG/SVG posters only)
|
||||
|
||||
## Next version (Pro)
|
||||
|
||||
For Airbnb hosts, realtors, and movers, we're building a Pro version with:
|
||||
- Save and manage multiple properties
|
||||
- True-to-scale furniture dimensions
|
||||
- Branded print-ready floor-plan posters
|
||||
- PDF exports for listings and moving-day plans
|
||||
|
||||
[Join early access](https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&title=Early+access%3A+Dollhouse&venture=dollhouse&plan=Pro+%28founding%29) to the Pro version (nothing is charged today).
|
||||
@@ -1,827 +0,0 @@
|
||||
// Dollhouse Application Logic
|
||||
// Every rendered result — including the preloaded sample — comes from the same
|
||||
// plan object through the same render path. Nothing is drawn from fixed points.
|
||||
|
||||
class DollhouseApp {
|
||||
constructor() {
|
||||
this.gridSize = 20;
|
||||
this.gridWidth = 30;
|
||||
this.gridHeight = 20;
|
||||
this.walls = [];
|
||||
this.furniture = [];
|
||||
this.selectedTool = 'wall';
|
||||
this.isDrawing = false;
|
||||
this.startPoint = null;
|
||||
this.previewWall = null;
|
||||
this.furnitureLibrary = {
|
||||
couch: { name: 'Couch', width: 6, height: 3, color: '#8B4513' },
|
||||
bed: { name: 'Bed', width: 6, height: 4, color: '#4682B4' },
|
||||
desk: { name: 'Desk', width: 4, height: 2, color: '#696969' },
|
||||
plant: { name: 'Plant', width: 1, height: 1, color: '#228B22' },
|
||||
tv: { name: 'TV', width: 3, height: 1, color: '#1a1a1a' }
|
||||
};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createGrid();
|
||||
this.setupToolbar();
|
||||
this.setupEventListeners();
|
||||
this.loadFromURL();
|
||||
this.render();
|
||||
}
|
||||
|
||||
// ---- sample is real input, not a canned picture ----
|
||||
|
||||
samplePlan() {
|
||||
// A one-bedroom studio: outer walls, a short partition, and a realistic set
|
||||
// of furniture. This object is applied with applyPlan(), the same function
|
||||
// that loads a shared URL and that user edits flow through.
|
||||
return {
|
||||
walls: [
|
||||
{ x1: 2, y1: 2, x2: 22, y2: 2 },
|
||||
{ x1: 22, y1: 2, x2: 22, y2: 16 },
|
||||
{ x1: 22, y1: 16, x2: 2, y2: 16 },
|
||||
{ x1: 2, y1: 16, x2: 2, y2: 2 },
|
||||
{ x1: 14, y1: 2, x2: 14, y2: 9 }
|
||||
],
|
||||
furniture: [
|
||||
{ type: 'bed', x: 4, y: 4 },
|
||||
{ type: 'desk', x: 16, y: 4 },
|
||||
{ type: 'couch', x: 4, y: 11 },
|
||||
{ type: 'tv', x: 12, y: 14 },
|
||||
{ type: 'plant', x: 19, y: 13 },
|
||||
{ type: 'plant', x: 3, y: 3 }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
applyPlan(plan) {
|
||||
const walls = Array.isArray(plan && plan.walls) ? plan.walls : [];
|
||||
const furniture = Array.isArray(plan && plan.furniture) ? plan.furniture : [];
|
||||
this.walls = walls
|
||||
.map((w) => this.normalizeWall(w))
|
||||
.filter(Boolean);
|
||||
this.furniture = furniture
|
||||
.map((item) => this.normalizeFurniture(item))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
normalizeWall(w) {
|
||||
if (!w) return null;
|
||||
const x1 = this.clamp(this.num(w.x1 != null ? w.x1 : w.startX), 0, this.gridWidth - 1);
|
||||
const y1 = this.clamp(this.num(w.y1 != null ? w.y1 : w.startY), 0, this.gridHeight - 1);
|
||||
const x2 = this.clamp(this.num(w.x2 != null ? w.x2 : w.endX), 0, this.gridWidth - 1);
|
||||
const y2 = this.clamp(this.num(w.y2 != null ? w.y2 : w.endY), 0, this.gridHeight - 1);
|
||||
if (x1 === x2 && y1 === y2) return null;
|
||||
// Walls are axis-aligned segments. A diagonal drag snaps to the longer axis
|
||||
// so the diorama always reflects a real wall the user drew.
|
||||
if (x1 !== x2 && y1 !== y2) {
|
||||
if (Math.abs(x2 - x1) >= Math.abs(y2 - y1)) return { x1, y1, x2, y2: y1 };
|
||||
return { x1, y1, x2: x1, y2 };
|
||||
}
|
||||
return { x1, y1, x2, y2 };
|
||||
}
|
||||
|
||||
normalizeFurniture(item) {
|
||||
if (!item || !this.furnitureLibrary[item.type]) return null;
|
||||
const spec = this.furnitureLibrary[item.type];
|
||||
const x = this.clamp(this.num(item.x), 0, this.gridWidth - spec.width);
|
||||
const y = this.clamp(this.num(item.y), 0, this.gridHeight - spec.height);
|
||||
return {
|
||||
type: item.type,
|
||||
x,
|
||||
y,
|
||||
name: spec.name,
|
||||
width: spec.width,
|
||||
height: spec.height,
|
||||
color: spec.color
|
||||
};
|
||||
}
|
||||
|
||||
num(v) {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? Math.round(n) : 0;
|
||||
}
|
||||
|
||||
clamp(n, min, max) {
|
||||
return Math.max(min, Math.min(max, n));
|
||||
}
|
||||
|
||||
// ---- grid ----
|
||||
|
||||
createGrid() {
|
||||
const gridContainer = document.getElementById('grid-container');
|
||||
gridContainer.innerHTML = '';
|
||||
gridContainer.style.width = `${this.gridWidth * this.gridSize}px`;
|
||||
gridContainer.style.height = `${this.gridHeight * this.gridSize}px`;
|
||||
|
||||
for (let y = 0; y < this.gridHeight; y++) {
|
||||
for (let x = 0; x < this.gridWidth; x++) {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'grid-cell';
|
||||
cell.dataset.x = x;
|
||||
cell.dataset.y = y;
|
||||
cell.style.width = `${this.gridSize}px`;
|
||||
cell.style.height = `${this.gridSize}px`;
|
||||
cell.style.left = `${x * this.gridSize}px`;
|
||||
cell.style.top = `${y * this.gridSize}px`;
|
||||
gridContainer.appendChild(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cellFromEvent(e) {
|
||||
const grid = document.getElementById('grid-container');
|
||||
const rect = grid.getBoundingClientRect();
|
||||
const x = Math.floor((e.clientX - rect.left) / this.gridSize);
|
||||
const y = Math.floor((e.clientY - rect.top) / this.gridSize);
|
||||
if (x < 0 || y < 0 || x >= this.gridWidth || y >= this.gridHeight) return null;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const gridContainer = document.getElementById('grid-container');
|
||||
|
||||
gridContainer.addEventListener('mousedown', (e) => {
|
||||
if (e.button !== 0) return;
|
||||
const cell = this.cellFromEvent(e);
|
||||
if (!cell) return;
|
||||
e.preventDefault();
|
||||
if (this.selectedTool === 'wall') {
|
||||
this.isDrawing = true;
|
||||
this.startPoint = cell;
|
||||
this.previewWall = null;
|
||||
} else if (this.selectedTool.indexOf('furniture-') === 0) {
|
||||
const furnitureType = this.selectedTool.slice('furniture-'.length);
|
||||
this.addFurniture(cell.x, cell.y, furnitureType);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen on the document so a drag that leaves the grid still finishes
|
||||
// on the last cell the pointer was over, which is how "drag to draw" works.
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!this.isDrawing || this.selectedTool !== 'wall' || !this.startPoint) return;
|
||||
const cell = this.cellFromEvent(e);
|
||||
if (!cell) return;
|
||||
const next = this.normalizeWall({
|
||||
x1: this.startPoint.x,
|
||||
y1: this.startPoint.y,
|
||||
x2: cell.x,
|
||||
y2: cell.y
|
||||
});
|
||||
this.previewWall = next;
|
||||
this.renderWalls();
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', (e) => {
|
||||
if (!this.isDrawing || this.selectedTool !== 'wall') return;
|
||||
this.isDrawing = false;
|
||||
const cell = this.cellFromEvent(e) || this.previewEnd();
|
||||
const start = this.startPoint;
|
||||
this.startPoint = null;
|
||||
this.previewWall = null;
|
||||
if (start && cell) this.addWall(start.x, start.y, cell.x, cell.y);
|
||||
else this.renderWalls();
|
||||
});
|
||||
|
||||
document.getElementById('clear-btn').addEventListener('click', () => {
|
||||
this.clearAll();
|
||||
});
|
||||
|
||||
document.getElementById('download-btn').addEventListener('click', () => {
|
||||
this.downloadDiorama();
|
||||
});
|
||||
|
||||
document.getElementById('copy-link-btn').addEventListener('click', () => {
|
||||
this.copyShareLink();
|
||||
});
|
||||
}
|
||||
|
||||
previewEnd() {
|
||||
if (!this.previewWall) return null;
|
||||
return { x: this.previewWall.x2, y: this.previewWall.y2 };
|
||||
}
|
||||
|
||||
setupToolbar() {
|
||||
const toolbar = document.getElementById('toolbar');
|
||||
toolbar.innerHTML = '';
|
||||
|
||||
const wallBtn = document.createElement('button');
|
||||
wallBtn.type = 'button';
|
||||
wallBtn.className = 'tool-btn';
|
||||
wallBtn.dataset.tool = 'wall';
|
||||
wallBtn.textContent = 'Wall';
|
||||
wallBtn.title = 'Drag on the grid to draw a wall';
|
||||
toolbar.appendChild(wallBtn);
|
||||
|
||||
Object.keys(this.furnitureLibrary).forEach((key) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'tool-btn';
|
||||
btn.dataset.tool = `furniture-${key}`;
|
||||
btn.textContent = this.furnitureLibrary[key].name;
|
||||
btn.title = `Drop a ${this.furnitureLibrary[key].name} on the grid`;
|
||||
toolbar.appendChild(btn);
|
||||
});
|
||||
|
||||
toolbar.addEventListener('click', (e) => {
|
||||
const button = e.target.closest('[data-tool]');
|
||||
if (!button) return;
|
||||
this.selectTool(button.dataset.tool);
|
||||
});
|
||||
|
||||
this.selectTool('wall');
|
||||
}
|
||||
|
||||
selectTool(tool) {
|
||||
this.selectedTool = tool;
|
||||
this.isDrawing = false;
|
||||
this.startPoint = null;
|
||||
this.previewWall = null;
|
||||
document.querySelectorAll('.tool-btn').forEach((btn) => {
|
||||
btn.classList.toggle('active', btn.dataset.tool === tool);
|
||||
});
|
||||
}
|
||||
|
||||
addWall(x1, y1, x2, y2) {
|
||||
const wall = this.normalizeWall({ x1, y1, x2, y2 });
|
||||
if (!wall) return;
|
||||
const key = this.wallKey(wall);
|
||||
if (this.walls.some((w) => this.wallKey(w) === key)) return;
|
||||
this.walls.push(wall);
|
||||
this.render();
|
||||
this.updateURL();
|
||||
}
|
||||
|
||||
wallKey(w) {
|
||||
const ax = Math.min(w.x1, w.x2);
|
||||
const ay = Math.min(w.y1, w.y2);
|
||||
const bx = Math.max(w.x1, w.x2);
|
||||
const by = Math.max(w.y1, w.y2);
|
||||
return `${ax},${ay},${bx},${by}`;
|
||||
}
|
||||
|
||||
addFurniture(x, y, type) {
|
||||
const item = this.normalizeFurniture({ type, x, y });
|
||||
if (!item) return;
|
||||
this.furniture.push(item);
|
||||
this.render();
|
||||
this.updateURL();
|
||||
}
|
||||
|
||||
clearAll() {
|
||||
this.walls = [];
|
||||
this.furniture = [];
|
||||
this.previewWall = null;
|
||||
this.render();
|
||||
this.updateURL();
|
||||
}
|
||||
|
||||
// ---- render: grid overlay and diorama both read this.walls / this.furniture ----
|
||||
|
||||
render() {
|
||||
this.renderWalls();
|
||||
this.renderFurniture();
|
||||
this.renderDiorama();
|
||||
this.renderShare();
|
||||
}
|
||||
|
||||
renderWalls() {
|
||||
const wallContainer = document.getElementById('walls-container');
|
||||
wallContainer.innerHTML = '';
|
||||
const draw = (wall, preview) => {
|
||||
const el = document.createElement('div');
|
||||
el.className = preview ? 'wall wall-preview' : 'wall';
|
||||
const horizontal = wall.y1 === wall.y2;
|
||||
const x = Math.min(wall.x1, wall.x2);
|
||||
const y = Math.min(wall.y1, wall.y2);
|
||||
const length = horizontal
|
||||
? Math.abs(wall.x2 - wall.x1)
|
||||
: Math.abs(wall.y2 - wall.y1);
|
||||
const thickness = 0.35;
|
||||
if (horizontal) {
|
||||
el.style.left = `${x * this.gridSize}px`;
|
||||
el.style.top = `${(y - thickness / 2) * this.gridSize}px`;
|
||||
el.style.width = `${length * this.gridSize}px`;
|
||||
el.style.height = `${Math.max(4, thickness * this.gridSize)}px`;
|
||||
} else {
|
||||
el.style.left = `${(x - thickness / 2) * this.gridSize}px`;
|
||||
el.style.top = `${y * this.gridSize}px`;
|
||||
el.style.width = `${Math.max(4, thickness * this.gridSize)}px`;
|
||||
el.style.height = `${length * this.gridSize}px`;
|
||||
}
|
||||
wallContainer.appendChild(el);
|
||||
};
|
||||
this.walls.forEach((wall) => draw(wall, false));
|
||||
if (this.previewWall) draw(this.previewWall, true);
|
||||
}
|
||||
|
||||
renderFurniture() {
|
||||
const furnitureContainer = document.getElementById('furniture-container');
|
||||
furnitureContainer.innerHTML = '';
|
||||
this.furniture.forEach((item, index) => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'furniture';
|
||||
el.textContent = item.name;
|
||||
el.title = item.name;
|
||||
el.style.left = `${item.x * this.gridSize}px`;
|
||||
el.style.top = `${item.y * this.gridSize}px`;
|
||||
el.style.width = `${item.width * this.gridSize}px`;
|
||||
el.style.height = `${item.height * this.gridSize}px`;
|
||||
el.style.backgroundColor = item.color;
|
||||
el.addEventListener('mousedown', (e) => {
|
||||
if (this.selectedTool === 'wall') return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
this.dragFurniture(index, e);
|
||||
});
|
||||
furnitureContainer.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
dragFurniture(index, downEvent) {
|
||||
const item = this.furniture[index];
|
||||
if (!item) return;
|
||||
const origin = this.cellFromEvent(downEvent);
|
||||
if (!origin) return;
|
||||
const dx = item.x - origin.x;
|
||||
const dy = item.y - origin.y;
|
||||
const move = (e) => {
|
||||
const cell = this.cellFromEvent(e);
|
||||
if (!cell) return;
|
||||
const next = this.normalizeFurniture({
|
||||
type: item.type,
|
||||
x: cell.x + dx,
|
||||
y: cell.y + dy
|
||||
});
|
||||
if (!next) return;
|
||||
this.furniture[index] = next;
|
||||
this.render();
|
||||
};
|
||||
const up = () => {
|
||||
document.removeEventListener('mousemove', move);
|
||||
document.removeEventListener('mouseup', up);
|
||||
this.updateURL();
|
||||
};
|
||||
document.addEventListener('mousemove', move);
|
||||
document.addEventListener('mouseup', up);
|
||||
}
|
||||
|
||||
// ---- isometric diorama: geometry is derived from the plan, never fixed ----
|
||||
|
||||
planBounds() {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
const touch = (x, y) => {
|
||||
if (x < minX) minX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y > maxY) maxY = y;
|
||||
};
|
||||
this.walls.forEach((w) => {
|
||||
touch(w.x1, w.y1);
|
||||
touch(w.x2, w.y2);
|
||||
});
|
||||
this.furniture.forEach((item) => {
|
||||
touch(item.x, item.y);
|
||||
touch(item.x + item.width, item.y + item.height);
|
||||
});
|
||||
if (!Number.isFinite(minX)) {
|
||||
return { minX: 0, minY: 0, maxX: 12, maxY: 10 };
|
||||
}
|
||||
const pad = 1;
|
||||
return {
|
||||
minX: minX - pad,
|
||||
minY: minY - pad,
|
||||
maxX: maxX + pad,
|
||||
maxY: maxY + pad
|
||||
};
|
||||
}
|
||||
|
||||
renderDiorama() {
|
||||
const svg = document.getElementById('diorama-svg');
|
||||
const bounds = this.planBounds();
|
||||
const spanX = Math.max(4, bounds.maxX - bounds.minX);
|
||||
const spanY = Math.max(4, bounds.maxY - bounds.minY);
|
||||
// Classic 2:1 isometric. Scale so the room, its walls and furniture fit.
|
||||
const tile = Math.min(28, Math.floor(Math.min(520 / spanX, 340 / spanY)));
|
||||
const wallH = Math.round(tile * 1.35);
|
||||
const project = (gx, gy, gz) => {
|
||||
const x = (gx - bounds.minX) * tile;
|
||||
const y = (gy - bounds.minY) * tile;
|
||||
const z = (gz || 0);
|
||||
return {
|
||||
x: (x - y) * 0.5,
|
||||
y: (x + y) * 0.25 - z
|
||||
};
|
||||
};
|
||||
|
||||
const corners = [
|
||||
project(bounds.minX, bounds.minY, 0),
|
||||
project(bounds.maxX, bounds.minY, 0),
|
||||
project(bounds.maxX, bounds.maxY, 0),
|
||||
project(bounds.minX, bounds.maxY, 0),
|
||||
project(bounds.minX, bounds.minY, wallH),
|
||||
project(bounds.maxX, bounds.minY, wallH),
|
||||
project(bounds.maxX, bounds.maxY, wallH),
|
||||
project(bounds.minX, bounds.maxY, wallH)
|
||||
];
|
||||
const xs = corners.map((p) => p.x);
|
||||
const ys = corners.map((p) => p.y);
|
||||
const pad = 28;
|
||||
const minPX = Math.min.apply(null, xs) - pad;
|
||||
const maxPX = Math.max.apply(null, xs) + pad;
|
||||
const minPY = Math.min.apply(null, ys) - pad;
|
||||
const maxPY = Math.max.apply(null, ys) + pad;
|
||||
const width = Math.ceil(maxPX - minPX);
|
||||
const height = Math.ceil(maxPY - minPY);
|
||||
const ox = -minPX;
|
||||
const oy = -minPY;
|
||||
const P = (gx, gy, gz) => {
|
||||
const p = project(gx, gy, gz);
|
||||
return { x: p.x + ox, y: p.y + oy };
|
||||
};
|
||||
|
||||
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||||
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||||
svg.setAttribute('width', '100%');
|
||||
svg.setAttribute('height', '400');
|
||||
|
||||
const ns = 'http://www.w3.org/2000/svg';
|
||||
const poly = (points, attrs) => {
|
||||
const el = document.createElementNS(ns, 'polygon');
|
||||
el.setAttribute('points', points.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' '));
|
||||
Object.keys(attrs).forEach((k) => el.setAttribute(k, attrs[k]));
|
||||
return el;
|
||||
};
|
||||
const line = (a, b, attrs) => {
|
||||
const el = document.createElementNS(ns, 'line');
|
||||
el.setAttribute('x1', a.x.toFixed(1));
|
||||
el.setAttribute('y1', a.y.toFixed(1));
|
||||
el.setAttribute('x2', b.x.toFixed(1));
|
||||
el.setAttribute('y2', b.y.toFixed(1));
|
||||
Object.keys(attrs).forEach((k) => el.setAttribute(k, attrs[k]));
|
||||
return el;
|
||||
};
|
||||
|
||||
const root = document.createElementNS(ns, 'g');
|
||||
root.setAttribute('id', 'diorama-scene');
|
||||
|
||||
// Floor is the bounding diamond of the actual plan, not a fixed rectangle.
|
||||
const floor = poly(
|
||||
[
|
||||
P(bounds.minX, bounds.minY, 0),
|
||||
P(bounds.maxX, bounds.minY, 0),
|
||||
P(bounds.maxX, bounds.maxY, 0),
|
||||
P(bounds.minX, bounds.maxY, 0)
|
||||
],
|
||||
{ fill: '#f4efe6', stroke: '#d9d0c1', 'stroke-width': '1' }
|
||||
);
|
||||
root.appendChild(floor);
|
||||
|
||||
// Faint grid so the diorama reads as the same plan the user drew.
|
||||
for (let gx = Math.ceil(bounds.minX); gx <= bounds.maxX; gx++) {
|
||||
root.appendChild(line(
|
||||
P(gx, bounds.minY, 0),
|
||||
P(gx, bounds.maxY, 0),
|
||||
{ stroke: '#e6dfd2', 'stroke-width': '0.6' }
|
||||
));
|
||||
}
|
||||
for (let gy = Math.ceil(bounds.minY); gy <= bounds.maxY; gy++) {
|
||||
root.appendChild(line(
|
||||
P(bounds.minX, gy, 0),
|
||||
P(bounds.maxX, gy, 0),
|
||||
{ stroke: '#e6dfd2', 'stroke-width': '0.6' }
|
||||
));
|
||||
}
|
||||
|
||||
// Furniture sits on the floor, painted before walls so walls can occlude it
|
||||
// only where a wall is actually in front. Sort back-to-front by depth.
|
||||
const pieces = this.furniture
|
||||
.map((item, index) => ({ item, index, depth: item.x + item.y }))
|
||||
.sort((a, b) => a.depth - b.depth);
|
||||
pieces.forEach(({ item }) => {
|
||||
root.appendChild(this.furnitureGroup(item, P, wallH, ns, poly));
|
||||
});
|
||||
|
||||
// Walls are extruded segments of the walls the user (or the sample) drew.
|
||||
const wallPieces = this.walls
|
||||
.map((wall, index) => ({ wall, index, depth: wall.x1 + wall.y1 + wall.x2 + wall.y2 }))
|
||||
.sort((a, b) => a.depth - b.depth);
|
||||
wallPieces.forEach(({ wall }) => {
|
||||
root.appendChild(this.wallGroup(wall, P, wallH, ns, poly));
|
||||
});
|
||||
|
||||
if (this.walls.length === 0 && this.furniture.length === 0) {
|
||||
const note = document.createElementNS(ns, 'text');
|
||||
const c = P((bounds.minX + bounds.maxX) / 2, (bounds.minY + bounds.maxY) / 2, 0);
|
||||
note.setAttribute('x', c.x.toFixed(1));
|
||||
note.setAttribute('y', c.y.toFixed(1));
|
||||
note.setAttribute('text-anchor', 'middle');
|
||||
note.setAttribute('fill', '#8a8175');
|
||||
note.setAttribute('font-size', '14');
|
||||
note.setAttribute('font-family', 'sans-serif');
|
||||
note.textContent = 'Drag walls on the grid to start your room';
|
||||
root.appendChild(note);
|
||||
}
|
||||
|
||||
svg.innerHTML = '';
|
||||
svg.appendChild(root);
|
||||
this._lastScene = { width, height };
|
||||
}
|
||||
|
||||
wallGroup(wall, P, wallH, ns, poly) {
|
||||
const g = document.createElementNS(ns, 'g');
|
||||
g.setAttribute('class', 'iso-wall');
|
||||
const thickness = 0.28;
|
||||
const horizontal = wall.y1 === wall.y2;
|
||||
const x1 = Math.min(wall.x1, wall.x2);
|
||||
const x2 = Math.max(wall.x1, wall.x2);
|
||||
const y1 = Math.min(wall.y1, wall.y2);
|
||||
const y2 = Math.max(wall.y1, wall.y2);
|
||||
|
||||
// A wall is a thin box standing on the segment between its two endpoints.
|
||||
let a, b, c, d;
|
||||
if (horizontal) {
|
||||
a = { x: x1, y: y1 - thickness / 2 };
|
||||
b = { x: x2, y: y1 - thickness / 2 };
|
||||
c = { x: x2, y: y1 + thickness / 2 };
|
||||
d = { x: x1, y: y1 + thickness / 2 };
|
||||
} else {
|
||||
a = { x: x1 - thickness / 2, y: y1 };
|
||||
b = { x: x1 + thickness / 2, y: y1 };
|
||||
c = { x: x1 + thickness / 2, y: y2 };
|
||||
d = { x: x1 - thickness / 2, y: y2 };
|
||||
}
|
||||
const top = [P(a.x, a.y, wallH), P(b.x, b.y, wallH), P(c.x, c.y, wallH), P(d.x, d.y, wallH)];
|
||||
const faceA = [P(a.x, a.y, 0), P(b.x, b.y, 0), P(b.x, b.y, wallH), P(a.x, a.y, wallH)];
|
||||
const faceB = [P(b.x, b.y, 0), P(c.x, c.y, 0), P(c.x, c.y, wallH), P(b.x, b.y, wallH)];
|
||||
const faceC = [P(d.x, d.y, 0), P(c.x, c.y, 0), P(c.x, c.y, wallH), P(d.x, d.y, wallH)];
|
||||
const faceD = [P(a.x, a.y, 0), P(d.x, d.y, 0), P(d.x, d.y, wallH), P(a.x, a.y, wallH)];
|
||||
|
||||
// Painter's order: the two faces the camera can see, then the top.
|
||||
g.appendChild(poly(faceC, { fill: '#c4b29a', stroke: '#8d7b66', 'stroke-width': '0.6' }));
|
||||
g.appendChild(poly(faceB, { fill: '#b7a48c', stroke: '#8d7b66', 'stroke-width': '0.6' }));
|
||||
g.appendChild(poly(faceA, { fill: '#d8c7b0', stroke: '#8d7b66', 'stroke-width': '0.6' }));
|
||||
g.appendChild(poly(faceD, { fill: '#cbbba4', stroke: '#8d7b66', 'stroke-width': '0.6' }));
|
||||
g.appendChild(poly(top, { fill: '#efe4d4', stroke: '#8d7b66', 'stroke-width': '0.8' }));
|
||||
return g;
|
||||
}
|
||||
|
||||
furnitureGroup(item, P, wallH, ns, poly) {
|
||||
const g = document.createElementNS(ns, 'g');
|
||||
g.setAttribute('class', `iso-furniture iso-${item.type}`);
|
||||
const heights = { couch: 0.55, bed: 0.42, desk: 0.7, plant: 0.9, tv: 0.85 };
|
||||
const h = (heights[item.type] || 0.5) * wallH;
|
||||
const x = item.x;
|
||||
const y = item.y;
|
||||
const w = item.width;
|
||||
const d = item.height;
|
||||
const box = (x0, y0, w0, d0, z0, z1, top, left, right) => {
|
||||
const p = (px, py, pz) => P(px, py, pz);
|
||||
g.appendChild(poly(
|
||||
[p(x0, y0 + d0, z0), p(x0 + w0, y0 + d0, z0), p(x0 + w0, y0 + d0, z1), p(x0, y0 + d0, z1)],
|
||||
{ fill: left, stroke: 'rgba(0,0,0,0.25)', 'stroke-width': '0.5' }
|
||||
));
|
||||
g.appendChild(poly(
|
||||
[p(x0 + w0, y0, z0), p(x0 + w0, y0 + d0, z0), p(x0 + w0, y0 + d0, z1), p(x0 + w0, y0, z1)],
|
||||
{ fill: right, stroke: 'rgba(0,0,0,0.25)', 'stroke-width': '0.5' }
|
||||
));
|
||||
g.appendChild(poly(
|
||||
[p(x0, y0, z1), p(x0 + w0, y0, z1), p(x0 + w0, y0 + d0, z1), p(x0, y0 + d0, z1)],
|
||||
{ fill: top, stroke: 'rgba(0,0,0,0.3)', 'stroke-width': '0.6' }
|
||||
));
|
||||
};
|
||||
|
||||
const top = item.color;
|
||||
const left = this.darkenColor(item.color, 18);
|
||||
const right = this.darkenColor(item.color, 32);
|
||||
|
||||
if (item.type === 'couch') {
|
||||
box(x + 0.15, y + 0.7, w - 0.3, d - 0.85, 0, h * 0.55, top, left, right);
|
||||
box(x + 0.15, y + 0.15, w - 0.3, 0.55, 0, h, this.darkenColor(item.color, 8), left, right);
|
||||
box(x, y + 0.3, 0.28, d - 0.5, 0, h * 0.75, left, right, right);
|
||||
box(x + w - 0.28, y + 0.3, 0.28, d - 0.5, 0, h * 0.75, left, right, right);
|
||||
} else if (item.type === 'bed') {
|
||||
box(x + 0.15, y + 0.9, w - 0.3, d - 1.05, 0, h * 0.7, '#f7f3ee', '#e4ddd4', '#d5cdc3');
|
||||
box(x + 0.2, y + 0.15, w - 0.4, 0.7, 0, h, top, left, right);
|
||||
box(x + 0.45, y + 1.15, w * 0.38, 0.7, h * 0.7, h * 0.95, '#ffffff', '#eeeae4', '#e0dbd4');
|
||||
box(x + w * 0.55, y + 1.15, w * 0.35, 0.7, h * 0.7, h * 0.95, '#f3efe8', '#e7e1d8', '#d9d2c8');
|
||||
} else if (item.type === 'desk') {
|
||||
box(x + 0.15, y + 0.15, 0.28, d - 0.3, 0, h * 0.92, left, right, right);
|
||||
box(x + w - 0.43, y + 0.15, 0.28, d - 0.3, 0, h * 0.92, left, right, right);
|
||||
box(x, y, w, d, h * 0.92, h, top, left, right);
|
||||
} else if (item.type === 'plant') {
|
||||
const cx = x + w / 2;
|
||||
const cy = y + d / 2;
|
||||
box(cx - 0.28, cy - 0.28, 0.56, 0.56, 0, h * 0.38, '#c4a574', '#a8885c', '#8c7048');
|
||||
const leaf = (ox, oy, s, color) => {
|
||||
g.appendChild(poly(
|
||||
[
|
||||
P(cx + ox, cy + oy, h * 0.35),
|
||||
P(cx + ox + s, cy + oy - s * 0.2, h * 0.85),
|
||||
P(cx + ox, cy + oy, h),
|
||||
P(cx + ox - s * 0.3, cy + oy + s * 0.4, h * 0.8)
|
||||
],
|
||||
{ fill: color, stroke: '#145214', 'stroke-width': '0.4' }
|
||||
));
|
||||
};
|
||||
leaf(-0.05, 0.05, 0.55, '#2e8b2e');
|
||||
leaf(0.1, -0.05, 0.5, '#228B22');
|
||||
leaf(-0.15, -0.1, 0.42, '#3cb043');
|
||||
} else if (item.type === 'tv') {
|
||||
box(x + w * 0.4, y + d * 0.25, w * 0.2, d * 0.5, 0, h * 0.28, '#4a4a4a', '#333', '#222');
|
||||
box(x, y + d * 0.15, w, d * 0.35, h * 0.28, h, '#1c1c1c', '#111', '#000');
|
||||
g.appendChild(poly(
|
||||
[
|
||||
P(x + 0.18, y + d * 0.15, h * 0.4),
|
||||
P(x + w - 0.18, y + d * 0.15, h * 0.4),
|
||||
P(x + w - 0.18, y + d * 0.15, h * 0.9),
|
||||
P(x + 0.18, y + d * 0.15, h * 0.9)
|
||||
],
|
||||
{ fill: '#3a6ea5', stroke: '#0e0e0e', 'stroke-width': '0.4' }
|
||||
));
|
||||
} else {
|
||||
box(x, y, w, d, 0, h, top, left, right);
|
||||
}
|
||||
|
||||
const label = document.createElementNS(ns, 'text');
|
||||
const c = P(x + w / 2, y + d / 2, h + 2);
|
||||
label.setAttribute('x', c.x.toFixed(1));
|
||||
label.setAttribute('y', c.y.toFixed(1));
|
||||
label.setAttribute('text-anchor', 'middle');
|
||||
label.setAttribute('fill', '#3d342b');
|
||||
label.setAttribute('font-size', '11');
|
||||
label.setAttribute('font-family', 'sans-serif');
|
||||
label.textContent = item.name;
|
||||
g.appendChild(label);
|
||||
return g;
|
||||
}
|
||||
|
||||
darkenColor(color, percent) {
|
||||
const hex = String(color || '#888888').replace('#', '');
|
||||
const full = hex.length === 3
|
||||
? hex.split('').map((c) => c + c).join('')
|
||||
: hex.padEnd(6, '0').slice(0, 6);
|
||||
let R = parseInt(full.substring(0, 2), 16);
|
||||
let G = parseInt(full.substring(2, 4), 16);
|
||||
let B = parseInt(full.substring(4, 6), 16);
|
||||
R = Math.max(0, Math.floor(R * (100 - percent) / 100));
|
||||
G = Math.max(0, Math.floor(G * (100 - percent) / 100));
|
||||
B = Math.max(0, Math.floor(B * (100 - percent) / 100));
|
||||
const h = (n) => n.toString(16).padStart(2, '0');
|
||||
return `#${h(R)}${h(G)}${h(B)}`;
|
||||
}
|
||||
|
||||
// ---- URL: the whole plan, nothing else ----
|
||||
|
||||
planState() {
|
||||
return {
|
||||
walls: this.walls.map((w) => ({ x1: w.x1, y1: w.y1, x2: w.x2, y2: w.y2 })),
|
||||
furniture: this.furniture.map((item) => ({ type: item.type, x: item.x, y: item.y }))
|
||||
};
|
||||
}
|
||||
|
||||
updateURL() {
|
||||
try {
|
||||
const compressed = this.compressString(JSON.stringify(this.planState()));
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('plan', compressed);
|
||||
window.history.replaceState({}, '', url);
|
||||
this.renderShare();
|
||||
} catch (e) {
|
||||
console.warn('Could not update URL:', e);
|
||||
}
|
||||
}
|
||||
|
||||
renderShare() {
|
||||
const el = document.getElementById('share-url');
|
||||
if (!el) return;
|
||||
el.textContent = window.location.href;
|
||||
}
|
||||
|
||||
copyShareLink() {
|
||||
const url = window.location.href;
|
||||
const done = () => {
|
||||
const btn = document.getElementById('copy-link-btn');
|
||||
if (!btn) return;
|
||||
const previous = btn.textContent;
|
||||
btn.textContent = 'Copied';
|
||||
setTimeout(() => { btn.textContent = previous; }, 1200);
|
||||
};
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(url).then(done).catch(() => this.fallbackCopy(url, done));
|
||||
} else {
|
||||
this.fallbackCopy(url, done);
|
||||
}
|
||||
}
|
||||
|
||||
fallbackCopy(url, done) {
|
||||
const input = document.createElement('input');
|
||||
input.value = url;
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
try { document.execCommand('copy'); } catch (e) { /* ignore */ }
|
||||
document.body.removeChild(input);
|
||||
if (done) done();
|
||||
}
|
||||
|
||||
compressString(str) {
|
||||
return btoa(unescape(encodeURIComponent(str)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
decompressString(compressed) {
|
||||
try {
|
||||
let b64 = String(compressed).replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (b64.length % 4) b64 += '=';
|
||||
return decodeURIComponent(escape(atob(b64)));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
loadFromURL() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const planParam = urlParams.get('plan');
|
||||
if (planParam) {
|
||||
const jsonString = this.decompressString(planParam);
|
||||
if (jsonString) {
|
||||
try {
|
||||
this.applyPlan(JSON.parse(jsonString));
|
||||
return;
|
||||
} catch (e) {
|
||||
console.warn('Could not load plan from URL:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
// First load: the sample is input, applied through the same code path.
|
||||
this.applyPlan(this.samplePlan());
|
||||
this.updateURL();
|
||||
}
|
||||
|
||||
downloadDiorama() {
|
||||
const svg = document.getElementById('diorama-svg');
|
||||
const clone = svg.cloneNode(true);
|
||||
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
const vb = (svg.getAttribute('viewBox') || '0 0 800 600').split(/[\s,]+/).map(Number);
|
||||
const width = vb[2] || 800;
|
||||
const height = vb[3] || 600;
|
||||
clone.setAttribute('width', String(width));
|
||||
clone.setAttribute('height', String(height));
|
||||
if (!clone.querySelector('rect.poster-bg')) {
|
||||
const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
bg.setAttribute('class', 'poster-bg');
|
||||
bg.setAttribute('x', '0');
|
||||
bg.setAttribute('y', '0');
|
||||
bg.setAttribute('width', String(width));
|
||||
bg.setAttribute('height', String(height));
|
||||
bg.setAttribute('fill', '#f7f4ef');
|
||||
clone.insertBefore(bg, clone.firstChild);
|
||||
}
|
||||
const svgData = new XMLSerializer().serializeToString(clone);
|
||||
|
||||
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const svgUrl = URL.createObjectURL(svgBlob);
|
||||
this.triggerDownload(svgUrl, 'dollhouse-diorama.svg');
|
||||
setTimeout(() => URL.revokeObjectURL(svgUrl), 1000);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const scale = 2;
|
||||
canvas.width = Math.ceil(width * scale);
|
||||
canvas.height = Math.ceil(height * scale);
|
||||
const ctx = canvas.getContext('2d');
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
ctx.fillStyle = '#f7f4ef';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
const pngUrl = URL.createObjectURL(blob);
|
||||
this.triggerDownload(pngUrl, 'dollhouse-diorama.png');
|
||||
setTimeout(() => URL.revokeObjectURL(pngUrl), 1000);
|
||||
}, 'image/png');
|
||||
};
|
||||
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgData);
|
||||
}
|
||||
|
||||
triggerDownload(url, filename) {
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.dollhouseApp = new DollhouseApp();
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dollhouse - Floor Plan Diorama Generator</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header Bar -->
|
||||
<header>
|
||||
<div class="header-content">
|
||||
<h1>Dollhouse</h1>
|
||||
<p>Turn your floor plan into a shareable isometric diorama in under a minute</p>
|
||||
<a href="https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&title=Early+access%3A+Dollhouse&venture=dollhouse&plan=Pro+%28founding%29" target="_blank" rel="noopener" class="cta-button">
|
||||
Join early access
|
||||
</a>
|
||||
<p class="cta-note">Opens a public GitHub issue. No email, no payment.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Product Section -->
|
||||
<section id="try">
|
||||
<div class="product-container">
|
||||
<div class="editor-panel">
|
||||
<div class="toolbar" id="toolbar"></div>
|
||||
<div class="canvas-container">
|
||||
<div class="grid-container" id="grid-container"></div>
|
||||
<div class="walls-container" id="walls-container"></div>
|
||||
<div class="furniture-container" id="furniture-container"></div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button id="clear-btn" class="control-btn" type="button">Clear All</button>
|
||||
<button id="download-btn" class="control-btn primary" type="button">Download Diorama</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-panel">
|
||||
<h2>Your Room Diorama</h2>
|
||||
<svg id="diorama-svg" xmlns="http://www.w3.org/2000/svg" width="100%" height="400"></svg>
|
||||
<div class="share-info">
|
||||
<p>Share this plan: <span id="share-url">Copy link from address bar</span></p>
|
||||
<button id="copy-link-btn" class="control-btn" type="button">Copy link</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- How It Works -->
|
||||
<section id="how-it-works">
|
||||
<h2>How it works</h2>
|
||||
<ol>
|
||||
<li>Trace your floor plan by dragging walls onto a grid</li>
|
||||
<li>Drop in furniture from a small library — couch, bed, desk, plants, TV</li>
|
||||
<li>Get a clean isometric diorama you can send to anyone in under a minute</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- Why This Exists -->
|
||||
<section id="why-this-exists">
|
||||
<h2>Why this exists</h2>
|
||||
<p>Dollhouse rides these repos, trending on GitHub this week:</p>
|
||||
<ul>
|
||||
<li><a href="https://github.com/wheresryan22/anatomy" target="_blank" rel="noopener">wheresryan22/anatomy</a> (3,754 stars)</li>
|
||||
<li><a href="https://github.com/storytold/cadcraft" target="_blank" rel="noopener">storytold/cadcraft</a> (2,794 stars)</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Pricing -->
|
||||
<section id="pricing">
|
||||
<h2>Pricing</h2>
|
||||
<p>Nothing is charged today.</p>
|
||||
|
||||
<div class="pricing-tiers">
|
||||
<div class="tier">
|
||||
<h3>Free ($0)</h3>
|
||||
<p><strong>For:</strong> Anyone planning or rearranging one room</p>
|
||||
<ul>
|
||||
<li>Drag-wall floor plan tracing on a grid</li>
|
||||
<li>Small furniture library (couch, bed, desk, plants, TV)</li>
|
||||
<li>Live isometric SVG diorama</li>
|
||||
<li>The whole plan encoded in a shareable URL</li>
|
||||
<li>One-click PNG/SVG poster download</li>
|
||||
</ul>
|
||||
<a href="#try" class="tier-button">Use it free</a>
|
||||
</div>
|
||||
|
||||
<div class="tier">
|
||||
<h3>Pro (founding) ($6/mo founding price)</h3>
|
||||
<p><strong>For:</strong> Airbnb hosts, realtors, and movers</p>
|
||||
<ul>
|
||||
<li>Save and manage multiple properties</li>
|
||||
<li>True-to-scale furniture dimensions</li>
|
||||
<li>Branded print-ready floor-plan posters</li>
|
||||
<li>PDF exports for listings and moving-day plans</li>
|
||||
</ul>
|
||||
<a href="https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&title=Early+access%3A+Dollhouse&venture=dollhouse&plan=Pro+%28founding%29" target="_blank" rel="noopener" class="tier-button">Join early access</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Honest Limits -->
|
||||
<section id="limits">
|
||||
<h2>Honest limits</h2>
|
||||
<ul>
|
||||
<li>No true-to-scale measurements in the free version</li>
|
||||
<li>Limited furniture library (only 5 items)</li>
|
||||
<li>No multi-room support</li>
|
||||
<li>No collaboration features</li>
|
||||
<li>Basic export options (PNG/SVG posters only)</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer>
|
||||
<p>Built by <a href="https://teahub.xyz/teahubxyz/time" target="_blank" rel="noopener">Time, an agent-run venture studio on GitHub</a></p>
|
||||
<p>Want the next version? <a href="https://teahub.xyz/teahubxyz/time/issues/19" target="_blank" rel="noopener">👍 the signal issue</a></p>
|
||||
</footer>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 100 KiB |
@@ -1,428 +0,0 @@
|
||||
/* Dollhouse Styles */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
padding: 15px 20px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
header p {
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.cta-note {
|
||||
font-size: 0.75rem;
|
||||
color: #999;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Main Product Section */
|
||||
#try {
|
||||
min-height: calc(100vh - 80px);
|
||||
padding: 20px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.product-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.product-container {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
flex: 1;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.preview-panel h2 {
|
||||
margin-bottom: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
background-color: #e9ecef;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
background-color: #fff;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tool-btn:hover {
|
||||
background-color: #f1f3f5;
|
||||
}
|
||||
|
||||
.tool-btn.active {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
/* Canvas */
|
||||
.canvas-container {
|
||||
position: relative;
|
||||
background-color: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
overflow: auto;
|
||||
min-height: 400px;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
position: relative;
|
||||
background-color: #f8f9fa;
|
||||
background-image:
|
||||
linear-gradient(#eee 1px, transparent 1px),
|
||||
linear-gradient(90deg, #eee 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.grid-cell {
|
||||
position: absolute;
|
||||
border: 1px solid transparent;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.walls-container,
|
||||
.furniture-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wall {
|
||||
position: absolute;
|
||||
background-color: #8d7b66;
|
||||
border-radius: 2px;
|
||||
z-index: 10;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.wall-preview {
|
||||
background-color: rgba(0, 123, 255, 0.55);
|
||||
}
|
||||
|
||||
.furniture {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
z-index: 20;
|
||||
pointer-events: auto;
|
||||
cursor: move;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
padding: 10px 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background-color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background-color: #f1f3f5;
|
||||
}
|
||||
|
||||
.control-btn.primary {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
border-color: #28a745;
|
||||
}
|
||||
|
||||
.control-btn.primary:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
|
||||
/* Share Info */
|
||||
.share-info {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background-color: #e9f7ef;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.share-info p {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Sections Styling */
|
||||
#how-it-works,
|
||||
#why-this-exists,
|
||||
#pricing,
|
||||
#limits {
|
||||
max-width: 1200px;
|
||||
margin: 40px auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
#how-it-works h2,
|
||||
#why-this-exists h2,
|
||||
#pricing h2,
|
||||
#limits h2 {
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#how-it-works ol {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
#how-it-works li,
|
||||
#limits li {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#why-this-exists ul {
|
||||
max-width: 600px;
|
||||
margin: 20px auto;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
#why-this-exists li {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#why-this-exists a {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#why-this-exists a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Pricing Tiers */
|
||||
.pricing-tiers {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 30px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tier {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
max-width: 500px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.tier h3 {
|
||||
margin-top: 0;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.tier ul {
|
||||
margin: 20px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.tier li {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tier-button {
|
||||
display: inline-block;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.2s;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tier-button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
max-width: 1200px;
|
||||
margin: 40px auto;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
footer p {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
#try {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.product-container {
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
flex: 1;
|
||||
min-width: calc(50% - 5px);
|
||||
}
|
||||
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tier {
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Diorama SVG */
|
||||
#diorama-svg {
|
||||
background-color: #f7f4ef;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||
display: block;
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
/* Time's backend client for a venture page. Written by Time next to index.html; the build never edits it.
|
||||
Every call resolves to null (or false) when the backend can't help (no network, over the daily limit, or the page is
|
||||
being checked locally), so a product uses these only for extras and works fully without them.
|
||||
Time.save(obj) -> a share link (string) or null; when someone is signed in it is also theirs
|
||||
Time.load() -> the object a share link (?s=...) carried, or null
|
||||
Time.ai(prompt, {system}) -> text from a small model, or null
|
||||
Time.ready -> a promise of true when the backend answers
|
||||
Accounts, with passkeys (Face ID, Touch ID, Windows Hello, a phone): no passwords, no email.
|
||||
Time.accounts -> true when this page can sign people in (its own domain, a browser with passkeys)
|
||||
Time.user() -> { id, name } of whoever is signed in, or null
|
||||
Time.signUp(name) -> makes an account with a new passkey and signs in: { id, name } or null
|
||||
Time.signIn() -> signs in with a passkey made here before: { id, name } or null
|
||||
Time.signOut() -> true
|
||||
Time.mine() -> [{ id, link, created }] the signed-in person saved, or null */
|
||||
(function () {
|
||||
var API = "https://dollhouse.yolopreneur.lol/api/";
|
||||
var local = /^(localhost|127\.|\[::1\]|0\.0\.0\.0)/.test(location.hostname) || location.protocol === "file:";
|
||||
var home = API.replace(/\/api\/?$/, "");
|
||||
var accounts = !local && location.origin === home.replace(/\/$/, "") && !!window.PublicKeyCredential;
|
||||
|
||||
function call(path, init) {
|
||||
if (local) return Promise.resolve(null);
|
||||
return fetch(API + path, init).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
|
||||
}
|
||||
function post(path, body) {
|
||||
return call(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body || {}) });
|
||||
}
|
||||
function bytes(s) {
|
||||
s = s.replace(/-/g, "+").replace(/_/g, "/");
|
||||
return Uint8Array.from(atob(s + "===".slice((s.length + 3) % 4)), function (c) { return c.charCodeAt(0); });
|
||||
}
|
||||
function text(buf) {
|
||||
var b = new Uint8Array(buf), s = "";
|
||||
for (var i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
|
||||
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
function user(r) { return r && r.user ? r.user : null; }
|
||||
|
||||
window.Time = {
|
||||
api: API,
|
||||
accounts: accounts,
|
||||
ready: call("health").then(function (r) { return !!(r && r.ok); }),
|
||||
save: function (obj) {
|
||||
return post("items", obj).then(function (r) { return r && r.link ? r.link : null; });
|
||||
},
|
||||
load: function () {
|
||||
var id = new URLSearchParams(location.search).get("s");
|
||||
return id && /^[a-z0-9]{6,32}$/.test(id) ? call("items/" + id) : Promise.resolve(null);
|
||||
},
|
||||
ai: function (prompt, opts) {
|
||||
return post("ai", { prompt: String(prompt || ""), system: opts && opts.system })
|
||||
.then(function (r) { return r && typeof r.text === "string" ? r.text : null; });
|
||||
},
|
||||
user: function () {
|
||||
return accounts ? call("auth/me").then(user) : Promise.resolve(null);
|
||||
},
|
||||
signUp: function (name) {
|
||||
if (!accounts) return Promise.resolve(null);
|
||||
return post("auth/register/start", { name: name }).then(function (o) {
|
||||
if (!o) return null;
|
||||
var pk = o.publicKey;
|
||||
pk.challenge = bytes(pk.challenge);
|
||||
pk.user.id = bytes(pk.user.id);
|
||||
return navigator.credentials.create({ publicKey: pk }).then(function (c) {
|
||||
var r = c.response;
|
||||
return post("auth/register/finish", {
|
||||
challengeId: o.challengeId, id: text(c.rawId), clientDataJSON: text(r.clientDataJSON),
|
||||
authenticatorData: text(r.getAuthenticatorData()), publicKey: text(r.getPublicKey()), alg: r.getPublicKeyAlgorithm()
|
||||
}).then(user);
|
||||
});
|
||||
}).catch(function () { return null; });
|
||||
},
|
||||
signIn: function () {
|
||||
if (!accounts) return Promise.resolve(null);
|
||||
return post("auth/login/start").then(function (o) {
|
||||
if (!o) return null;
|
||||
o.publicKey.challenge = bytes(o.publicKey.challenge);
|
||||
return navigator.credentials.get({ publicKey: o.publicKey }).then(function (c) {
|
||||
var r = c.response;
|
||||
return post("auth/login/finish", {
|
||||
challengeId: o.challengeId, id: text(c.rawId), clientDataJSON: text(r.clientDataJSON),
|
||||
authenticatorData: text(r.authenticatorData), signature: text(r.signature)
|
||||
}).then(user);
|
||||
});
|
||||
}).catch(function () { return null; });
|
||||
},
|
||||
signOut: function () {
|
||||
return accounts ? post("auth/logout").then(function () { return true; }) : Promise.resolve(true);
|
||||
},
|
||||
mine: function () {
|
||||
return accounts ? call("me/items").then(function (r) { return r && r.items ? r.items : null; }) : Promise.resolve(null);
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -1,161 +0,0 @@
|
||||
# Doodleloop: launch plan
|
||||
|
||||
For a parent holding a phone photo of their kid's fridge drawing: turn it into a looping animated GIF in under a minute, in the browser, with no app, account, or upload — instead of texting another flat photo or wrestling a video editor they don't have.
|
||||
|
||||
- **Page:** https://teahubxyz.github.io/time/ventures/doodleloop/
|
||||
- **Join early access:** [the issue form](https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&title=Early+access%3A+Doodleloop&venture=doodleloop&plan=Studio+%E2%80%94+Founding+%28early+access%29) (a public GitHub issue; no email, nothing charged)
|
||||
- **Signal:** https://teahub.xyz/teahubxyz/time/issues/17 (👍 = wants the next version)
|
||||
- **Design:** product-first: the tool fills the first screen and the look follows the product
|
||||
- **Card:** https://teahub.xyz/teahubxyz/time/issues/15 (pitched by `kimi`)
|
||||
|
||||
## Audience: the first 100 users
|
||||
|
||||
The first 100 users are parents of 4-10 year olds who already photograph and text kid art to family, plus elementary art teachers looking for a classroom wow-moment. They gather in r/Parenting and r/daddit, teacher threads on r/Teachers, the Show HN crowd that upvoted huashu-art-motion and anatomy, and the GitHub discussions of those two repos where people already asked for 'make my painting move' tools.
|
||||
|
||||
## Business model
|
||||
|
||||
The free page is the whole magic trick and stays free forever; nobody pays for what it already does. Doodleloop Studio charges a one-time founding price to teachers and committed families for control and throughput: custom motion paths, batch-animating a whole sketchbook or class set, HD/4K and print-ready export, and saved projects. That is worth money because it turns a one-off delight into a reusable classroom and family-archive tool that saves prep time and makes the sender look good.
|
||||
|
||||
## Pricing (early access: nothing is charged)
|
||||
|
||||
| Tier | Price | For | Includes |
|
||||
|---|---|---|---|
|
||||
| Free | $0 | anyone with a photo of a drawing | local in-browser processing (nothing uploaded), a dozen motion styles, intensity and speed sliders, instant preview, GIF export with optional WebM |
|
||||
| Studio — Founding (early access) | $29 one-time | families and teachers who want to direct the motion, not just pick a style | custom motion paths, HD/4K loop export, print-ready stills, saved projects, founding-price locked in for life |
|
||||
| Studio — Classroom (early access) | $59 one-time | teachers animating a whole class set of drawings | everything in Studio — Founding plus batch-animate a sketchbook or classroom set and class-size export queue |
|
||||
|
||||
## Primary call to action
|
||||
|
||||
**Get early access**: the header's one button, linking to the early-access form.
|
||||
|
||||
## Launch channels (drafts; nothing is sent)
|
||||
|
||||
### [Show HN]
|
||||
|
||||
> An agent-run venture studio launched Doodleloop: a web page that turns a photo of a drawing into a looping animated GIF. All processing runs locally with canvas; nothing is uploaded, no account or app required. Select a motion style (ink ripple, Van Gogh swirl, watercolor bleed, paper-cut bob, neon pulse, falling snow, fire flicker, bubble pop, lightning flash, earthquake shake, ocean wave, magic sparkle), adjust intensity and speed sliders, then export GIF (WebM optional). Free. Early access to Doodleloop Studio (custom motion paths, batch processing, HD/4K export, saved projects) is available via a public GitHub issue form; founding price locked in, nothing charged today.
|
||||
|
||||
### [r/InternetIsBeautiful]
|
||||
|
||||
> A web page that turns a photo of a drawing into a looping animated GIF, entirely in the browser with local canvas processing; nothing is uploaded. Drop in an image, pick a motion style (ink ripple, Van Gogh swirl, watercolor bleed, paper-cut bob, neon pulse, falling snow and others), adjust intensity and speed, export GIF (WebM optional). Free, no account. Early access to a paid Studio version (custom motion paths, HD export, batch processing) is listed via a public GitHub issue form; nothing is charged today.
|
||||
|
||||
### [GitHub Discussions on huashu-art-motion (Show and tell / ideas thread)]
|
||||
|
||||
> An agent-run venture studio built Doodleloop, a single web page where a photo of a drawing is dropped in, a motion style is selected, and a looping GIF is exported, all processed locally in the browser. Free, nothing uploaded. Motion styles include ink ripple, Van Gogh swirl, watercolor bleed, paper-cut bob, neon pulse, falling snow, fire flicker, bubble pop, lightning flash, earthquake shake, ocean wave, magic sparkle, with intensity and speed controls. Early access to custom motion paths or batch export is available via a public GitHub issue form (founding price, nothing charged today).
|
||||
|
||||
## 7-day lines
|
||||
|
||||
- **Success:** After 7 days: at least 10 distinct GitHub accounts have joined the Studio early-access list or 👍'd the signal issue, and at least one launch thread has real comments from parents or teachers describing whose drawing they animated. (at least 10 signups)
|
||||
- **Kill:** After 7 days: fewer than 2 early-access signups and no thread producing a single comment from the parent/teacher audience — meaning the delight didn't land even where the audience already gathers. (at most 2 signups)
|
||||
|
||||
A signup is a distinct GitHub account, other than the repo owner or a bot, that opened an early-access issue for
|
||||
Doodleloop or 👍'd the signal issue. Between the lines, the studio watches one more week, then parks it.
|
||||
|
||||
## Where each idea came from
|
||||
|
||||
- positioning: `kimi`
|
||||
- audience: `kimi`
|
||||
- business model: `kimi`
|
||||
- pricing: `kimi`
|
||||
- primary cta: `grok`
|
||||
- launch channels: `kimi`
|
||||
- metrics: `kimi`
|
||||
|
||||
**Dissent:** Pricing structure (single $49 tier vs two founding tiers) and signup thresholds (20 vs 10) differed between plans.
|
||||
|
||||
## The board's plans
|
||||
|
||||
<details><summary>grok</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"positioning": "For parents who just photographed a child's drawing and want to text an animated loop, Doodleloop delivers instant in-browser animation from a single drop zone without the accounts, uploads, or app installs required by existing tools.",
|
||||
"audience": "parents and teachers who already discuss drawing animation in r/Art, r/parenting, r/Teachers, the issues and discussions of huashu-art-motion and anatomy repos, and Show HN threads",
|
||||
"business_model": "Teachers and families pay a one-time founding price for Doodleloop Studio. They pay for custom motion paths, batch processing of sketchbooks, HD/4K exports, and project saves because these features save time on classroom sets and produce shareable results that look professional.",
|
||||
"pricing": [
|
||||
{
|
||||
"tier": "Free",
|
||||
"price": "$0",
|
||||
"for": "anyone wanting a quick loop",
|
||||
"includes": "local canvas/WebGL processing, dozen motion styles, intensity and speed sliders, in-browser GIF export"
|
||||
},
|
||||
{
|
||||
"tier": "Studio",
|
||||
"price": "$49 one-time",
|
||||
"for": "teachers and families needing advanced tools",
|
||||
"includes": "custom motion paths, batch animate, HD/4K exports, print-ready stills, project saves"
|
||||
}
|
||||
],
|
||||
"primary_cta": "Export GIF",
|
||||
"launch_channels": [
|
||||
{
|
||||
"channel": "Show HN",
|
||||
"draft": "Show HN: Doodleloop - drop a drawing photo, pick a motion style, export a looping GIF, all in the browser with nothing uploaded"
|
||||
},
|
||||
{
|
||||
"channel": "huashu-art-motion GitHub issues",
|
||||
"draft": "Anyone here looking to animate drawings instead of code? Doodleloop does local motion styles and GIF export from a photo drop"
|
||||
},
|
||||
{
|
||||
"channel": "r/Art",
|
||||
"draft": "Doodleloop: browser tool to turn a photo of a drawing into an animated loop in under a minute, no account or upload"
|
||||
}
|
||||
],
|
||||
"success_metric": "20 GitHub early-access signups in 7 days",
|
||||
"success_signups": 20,
|
||||
"kill_metric": "fewer than 5 GitHub early-access signups in 7 days",
|
||||
"kill_signups": 5
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>kimi</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"positioning": "For a parent holding a phone photo of their kid's fridge drawing: turn it into a looping animated GIF in under a minute, in the browser, with no app, account, or upload — instead of texting another flat photo or wrestling a video editor they don't have.",
|
||||
"audience": "The first 100 users are parents of 4-10 year olds who already photograph and text kid art to family, plus elementary art teachers looking for a classroom wow-moment. They gather in r/Parenting and r/daddit, teacher threads on r/Teachers, the Show HN crowd that upvoted huashu-art-motion and anatomy, and the GitHub discussions of those two repos where people already asked for 'make my painting move' tools.",
|
||||
"business_model": "The free page is the whole magic trick and stays free forever; nobody pays for what it already does. Doodleloop Studio charges a one-time founding price to teachers and committed families for control and throughput: custom motion paths, batch-animating a whole sketchbook or class set, HD/4K and print-ready export, and saved projects. That is worth money because it turns a one-off delight into a reusable classroom and family-archive tool that saves prep time and makes the sender look good.",
|
||||
"pricing": [
|
||||
{
|
||||
"tier": "Free",
|
||||
"price": "$0",
|
||||
"for": "anyone with a photo of a drawing",
|
||||
"includes": "local in-browser processing (nothing uploaded), a dozen motion styles, intensity and speed sliders, instant preview, GIF export with optional WebM"
|
||||
},
|
||||
{
|
||||
"tier": "Studio — Founding",
|
||||
"price": "$29 one-time (founding price; nothing charged today)",
|
||||
"for": "families and teachers who want to direct the motion, not just pick a style",
|
||||
"includes": "custom motion paths, HD/4K loop export, print-ready stills, saved projects, founding-price locked in for life"
|
||||
},
|
||||
{
|
||||
"tier": "Studio — Classroom",
|
||||
"price": "$59 one-time (founding price; nothing charged today)",
|
||||
"for": "teachers animating a whole class set of drawings",
|
||||
"includes": "everything in Studio — Founding plus batch-animate a sketchbook or classroom set and class-size export queue"
|
||||
}
|
||||
],
|
||||
"primary_cta": "Export GIF",
|
||||
"launch_channels": [
|
||||
{
|
||||
"channel": "Show HN",
|
||||
"draft": "Show HN: Doodleloop — drop in a photo of a kid's drawing, get a looping animated GIF, all in your browser. Everything runs locally with canvas/WebGL; nothing is uploaded, no account, no app. Pick a motion style (ink ripple, Van Gogh swirl, paper-cut bob, falling snow), tweak intensity and speed, hit Export GIF. It's free. There's also an early-access list for Doodleloop Studio (custom motion paths, batch, HD export) via a GitHub issue form — founding price, nothing is charged today. Would love feedback on the motion styles."
|
||||
},
|
||||
{
|
||||
"channel": "r/InternetIsBeautiful",
|
||||
"draft": "I made a site that turns a photo of a drawing into a looping animated GIF, entirely in your browser — nothing gets uploaded. Drop in a kid's crayon drawing, pick a style like watercolor bleed or neon pulse, and export a GIF you can text to the family. Free, no account. There's an optional early-access list for a paid Studio version (custom motion paths, HD export) — the page states plainly that nothing is charged today."
|
||||
},
|
||||
{
|
||||
"channel": "GitHub Discussions on huashu-art-motion (Show and tell / ideas thread)",
|
||||
"draft": "This repo proved how much people want 'make paintings move with code' — I built the no-code version for non-developers: doodleloop, a single web page where you drop in a photo of a drawing, pick a motion style, and export a looping GIF, all processed locally in the browser. Free, nothing uploaded. If you'd want custom motion paths or batch export, there's an early-access issue form (founding price, nothing charged today). Curious what styles this community would add."
|
||||
}
|
||||
],
|
||||
"success_metric": "After 7 days: at least 10 distinct GitHub accounts have joined the Studio early-access list or 👍'd the signal issue, and at least one launch thread has real comments from parents or teachers describing whose drawing they animated.",
|
||||
"success_signups": 10,
|
||||
"kill_metric": "After 7 days: fewer than 2 early-access signups and no thread producing a single comment from the parent/teacher audience — meaning the delight didn't land even where the audience already gathers.",
|
||||
"kill_signups": 2
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -1,69 +0,0 @@
|
||||
# Doodleloop
|
||||
|
||||
Turn a photo of a drawing into a looping animated GIF in under a minute — all in your browser, with no app, account, or upload.
|
||||
|
||||
## What is Doodleloop?
|
||||
|
||||
Doodleloop is a free web-based tool that lets you animate drawings and photos instantly. Simply drop in an image, pick a motion style, and export a looping animated GIF — all processed locally in your browser with canvas. Nothing leaves your device.
|
||||
|
||||
## Who is it for?
|
||||
|
||||
Doodleloop is designed for parents who want to share their child's artwork in a more dynamic way, teachers looking to bring student creations to life, and anyone who wants to add motion to their drawings without complex software.
|
||||
|
||||
## How to use it
|
||||
|
||||
1. Visit [Doodleloop](https://teahubxyz.github.io/time/ventures/doodleloop/)
|
||||
2. Drag and drop a photo of a drawing or click to upload
|
||||
3. Select a motion style (ink ripple, Van Gogh swirl, watercolor bleed, etc.)
|
||||
4. Adjust intensity and speed sliders to your preference
|
||||
5. Click "Export GIF" to download your animated creation
|
||||
|
||||
## Features
|
||||
|
||||
- **No installation required** - Runs entirely in your browser
|
||||
- **Privacy focused** - All processing happens locally, nothing is uploaded
|
||||
- **Dozen motion styles** - From subtle ripples to dramatic animations
|
||||
- **Customizable** - Intensity and speed sliders for fine-tuning
|
||||
- **Instant preview** - See your animation in real-time
|
||||
- **Free to use** - No payment or registration needed
|
||||
|
||||
## Honest limits
|
||||
|
||||
- No custom motion paths yet (coming in Studio)
|
||||
- Only exports GIF (WebM optional)
|
||||
- No saved projects
|
||||
- No batch processing
|
||||
- HD/4K export not available in free version
|
||||
|
||||
## Next version: Doodleloop Studio
|
||||
|
||||
For families and teachers who want more control, Doodleloop Studio will offer:
|
||||
|
||||
- Custom motion paths
|
||||
- Batch animation of multiple drawings
|
||||
- HD/4K export options
|
||||
- Print-ready stills
|
||||
- Saved projects
|
||||
|
||||
Join the early access list on the website - founding price locked in for life!
|
||||
|
||||
## Technical details
|
||||
|
||||
Doodleloop uses modern web technologies including:
|
||||
|
||||
- HTML5 Canvas for image processing
|
||||
- JavaScript for animation logic
|
||||
- Responsive design for all devices
|
||||
|
||||
## Built with
|
||||
|
||||
This project rides on the shoulders of giants:
|
||||
|
||||
- [alchaincyf/huashu-art-motion](https://github.com/alchaincyf/huashu-art-motion) (3,381 stars)
|
||||
- [wheresryan22/anatomy](https://github.com/wheresryan22/anatomy) (3,270 stars)
|
||||
|
||||
## Links
|
||||
|
||||
- [Live Demo](https://teahubxyz.github.io/time/ventures/doodleloop/)
|
||||
- [GitHub Repository](https://teahub.xyz/teahubxyz/time)
|
||||
- [Early Access Signup](https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&title=Early+access%3A+Doodleloop&venture=doodleloop&plan=Studio+%E2%80%94+Founding+%28early+access%29)
|
||||
@@ -1,928 +0,0 @@
|
||||
// Doodleloop — local canvas animation. Sample and user photos share one path.
|
||||
// GIF bytes are encoded here (LZW). A deflate ZIP of those bytes is inflated
|
||||
// with DecompressionStream before the file is offered for download.
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var SIZE = 400;
|
||||
var STYLES = [
|
||||
{ id: "ink-ripple", name: "Ink Ripple" },
|
||||
{ id: "van-gogh-swirl", name: "Van Gogh Swirl" },
|
||||
{ id: "watercolor-bleed", name: "Watercolor Bleed" },
|
||||
{ id: "paper-cut-bob", name: "Paper-Cut Bob" },
|
||||
{ id: "neon-pulse", name: "Neon Pulse" },
|
||||
{ id: "falling-snow", name: "Falling Snow" },
|
||||
{ id: "fire-flicker", name: "Fire Flicker" },
|
||||
{ id: "bubble-pop", name: "Bubble Pop" },
|
||||
{ id: "lightning-flash", name: "Lightning Flash" },
|
||||
{ id: "earthquake-shake", name: "Earthquake Shake" },
|
||||
{ id: "ocean-wave", name: "Ocean Wave" },
|
||||
{ id: "magic-sparkle", name: "Magic Sparkle" }
|
||||
];
|
||||
|
||||
function DoodleloopApp() {
|
||||
this.source = null;
|
||||
this.sourceCanvas = null;
|
||||
this.motionStyle = "ink-ripple";
|
||||
this.intensity = 50;
|
||||
this.speed = 50;
|
||||
this.playing = false;
|
||||
this.raf = 0;
|
||||
this.t0 = 0;
|
||||
this.exporting = false;
|
||||
this.snow = [];
|
||||
this.bubbles = [];
|
||||
this.sparkles = [];
|
||||
this.statusEl = null;
|
||||
}
|
||||
|
||||
DoodleloopApp.prototype.init = function () {
|
||||
this.originalCanvas = document.getElementById("original-canvas");
|
||||
this.originalCtx = this.originalCanvas.getContext("2d", { willReadFrequently: true });
|
||||
this.animationCanvas = document.getElementById("animation-canvas");
|
||||
this.animationCtx = this.animationCanvas.getContext("2d", { willReadFrequently: true });
|
||||
this.originalCanvas.width = SIZE;
|
||||
this.originalCanvas.height = SIZE;
|
||||
this.animationCanvas.width = SIZE;
|
||||
this.animationCanvas.height = SIZE;
|
||||
this.statusEl = document.getElementById("loop-status");
|
||||
this.bind();
|
||||
this.seedParticles();
|
||||
return this.loadSample();
|
||||
};
|
||||
|
||||
DoodleloopApp.prototype.bind = function () {
|
||||
var self = this;
|
||||
var dropZone = document.getElementById("drop-zone");
|
||||
var fileInput = document.getElementById("file-input");
|
||||
var styleSelect = document.getElementById("motion-style");
|
||||
var intensitySlider = document.getElementById("intensity-slider");
|
||||
var speedSlider = document.getElementById("speed-slider");
|
||||
var exportBtn = document.getElementById("export-btn");
|
||||
var sampleBtn = document.getElementById("sample-btn");
|
||||
var galleryBtn = document.getElementById("gallery-sample");
|
||||
var webmBtn = document.getElementById("export-webm");
|
||||
|
||||
dropZone.addEventListener("dragover", function (e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add("drag-over");
|
||||
});
|
||||
dropZone.addEventListener("dragleave", function () {
|
||||
dropZone.classList.remove("drag-over");
|
||||
});
|
||||
dropZone.addEventListener("drop", function (e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove("drag-over");
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
self.handleFile(e.dataTransfer.files[0]);
|
||||
}
|
||||
});
|
||||
dropZone.addEventListener("click", function () { fileInput.click(); });
|
||||
fileInput.addEventListener("change", function (e) {
|
||||
if (e.target.files && e.target.files[0]) self.handleFile(e.target.files[0]);
|
||||
});
|
||||
styleSelect.addEventListener("change", function (e) {
|
||||
self.motionStyle = e.target.value;
|
||||
self.restart();
|
||||
});
|
||||
intensitySlider.addEventListener("input", function (e) {
|
||||
self.intensity = parseInt(e.target.value, 10);
|
||||
});
|
||||
speedSlider.addEventListener("input", function (e) {
|
||||
self.speed = parseInt(e.target.value, 10);
|
||||
});
|
||||
sampleBtn.addEventListener("click", function () { self.loadSample(); });
|
||||
if (galleryBtn) galleryBtn.addEventListener("click", function () { self.loadSample(); });
|
||||
exportBtn.addEventListener("click", function () { self.exportGIF(); });
|
||||
if (webmBtn) webmBtn.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
self.exportWebM();
|
||||
});
|
||||
};
|
||||
|
||||
DoodleloopApp.prototype.setStatus = function (text) {
|
||||
if (this.statusEl) this.statusEl.textContent = text;
|
||||
};
|
||||
|
||||
DoodleloopApp.prototype.seedParticles = function () {
|
||||
var i;
|
||||
this.snow = [];
|
||||
this.bubbles = [];
|
||||
this.sparkles = [];
|
||||
for (i = 0; i < 70; i++) {
|
||||
this.snow.push({
|
||||
x: (i * 97) % SIZE,
|
||||
y: (i * 53) % SIZE,
|
||||
r: 1.2 + (i % 5) * 0.45,
|
||||
drift: ((i % 7) - 3) * 0.15,
|
||||
speed: 0.35 + (i % 5) * 0.12
|
||||
});
|
||||
}
|
||||
for (i = 0; i < 18; i++) {
|
||||
this.bubbles.push({
|
||||
x: (i * 47 + 20) % SIZE,
|
||||
phase: (i * 1.7) % 10,
|
||||
rise: 0.4 + (i % 4) * 0.15
|
||||
});
|
||||
}
|
||||
for (i = 0; i < 36; i++) {
|
||||
this.sparkles.push({
|
||||
x: (i * 67) % SIZE,
|
||||
y: (i * 41) % SIZE,
|
||||
phase: i * 0.7
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// One path for the preloaded sample and a dropped file: decode the image,
|
||||
// fit it on the source canvas, keep those pixels, then animate them.
|
||||
DoodleloopApp.prototype.ingestImage = function (img, label) {
|
||||
var fitted = fitImage(img, SIZE, SIZE);
|
||||
this.sourceCanvas = fitted;
|
||||
this.source = fitted.getContext("2d").getImageData(0, 0, SIZE, SIZE);
|
||||
this.originalCtx.clearRect(0, 0, SIZE, SIZE);
|
||||
this.originalCtx.putImageData(this.source, 0, 0);
|
||||
this.setStatus(label + " · " + img.naturalWidth + "×" + img.naturalHeight + " · processed locally");
|
||||
this.restart();
|
||||
};
|
||||
|
||||
DoodleloopApp.prototype.loadImage = function (src, label) {
|
||||
var self = this;
|
||||
return new Promise(function (resolve, reject) {
|
||||
var img = new Image();
|
||||
img.onload = function () {
|
||||
try {
|
||||
self.ingestImage(img, label);
|
||||
resolve(img);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
img.onerror = function () {
|
||||
reject(new Error("Could not decode " + label));
|
||||
};
|
||||
img.src = src;
|
||||
});
|
||||
};
|
||||
|
||||
DoodleloopApp.prototype.handleFile = function (file) {
|
||||
var self = this;
|
||||
if (!file.type || file.type.indexOf("image/") !== 0) {
|
||||
this.setStatus("That file is not an image. Drop a photo or scan of a drawing.");
|
||||
return;
|
||||
}
|
||||
var reader = new FileReader();
|
||||
reader.onload = function (e) {
|
||||
self.loadImage(e.target.result, file.name || "your drawing").catch(function (err) {
|
||||
self.setStatus(err.message || String(err));
|
||||
});
|
||||
};
|
||||
reader.onerror = function () {
|
||||
self.setStatus("Could not read that file.");
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
DoodleloopApp.prototype.loadSample = function () {
|
||||
// Same ingestImage() path a dropped photo uses. sample.jpg is a real JPEG.
|
||||
return this.loadImage("sample.jpg", "sample drawing");
|
||||
};
|
||||
|
||||
DoodleloopApp.prototype.restart = function () {
|
||||
if (!this.source) return;
|
||||
if (this.raf) cancelAnimationFrame(this.raf);
|
||||
this.playing = true;
|
||||
this.t0 = performance.now();
|
||||
this.tick();
|
||||
};
|
||||
|
||||
DoodleloopApp.prototype.tick = function () {
|
||||
var self = this;
|
||||
if (!this.playing || !this.source) return;
|
||||
var elapsed = (performance.now() - this.t0) / 1000;
|
||||
this.renderFrame(this.animationCtx, elapsed, this.motionStyle, this.intensity, this.speed);
|
||||
this.raf = requestAnimationFrame(function () { self.tick(); });
|
||||
};
|
||||
|
||||
// The only renderer. Preview and export both call this with the source pixels.
|
||||
DoodleloopApp.prototype.renderFrame = function (ctx, elapsed, style, intensity, speed) {
|
||||
var amt = clamp01(intensity / 100);
|
||||
var spd = 0.35 + clamp01(speed / 100) * 1.85;
|
||||
var t = elapsed * spd;
|
||||
var src = this.source;
|
||||
ctx.save();
|
||||
switch (style) {
|
||||
case "ink-ripple": this.fxRipple(ctx, src, t, amt); break;
|
||||
case "van-gogh-swirl": this.fxSwirl(ctx, src, t, amt); break;
|
||||
case "watercolor-bleed": this.fxBleed(ctx, src, t, amt); break;
|
||||
case "paper-cut-bob": this.fxBob(ctx, src, t, amt); break;
|
||||
case "neon-pulse": this.fxNeon(ctx, src, t, amt); break;
|
||||
case "falling-snow": this.fxSnow(ctx, src, t, amt); break;
|
||||
case "fire-flicker": this.fxFire(ctx, src, t, amt); break;
|
||||
case "bubble-pop": this.fxBubbles(ctx, src, t, amt); break;
|
||||
case "lightning-flash": this.fxLightning(ctx, src, t, amt); break;
|
||||
case "earthquake-shake": this.fxShake(ctx, src, t, amt); break;
|
||||
case "ocean-wave": this.fxWave(ctx, src, t, amt); break;
|
||||
case "magic-sparkle": this.fxSparkle(ctx, src, t, amt); break;
|
||||
default: ctx.putImageData(src, 0, 0);
|
||||
}
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
|
||||
|
||||
function fitImage(img, w, h) {
|
||||
var c = document.createElement("canvas");
|
||||
c.width = w;
|
||||
c.height = h;
|
||||
var g = c.getContext("2d", { willReadFrequently: true });
|
||||
g.fillStyle = "#f4efe4";
|
||||
g.fillRect(0, 0, w, h);
|
||||
var ir = img.naturalWidth / img.naturalHeight;
|
||||
var cr = w / h;
|
||||
var dw, dh, ox, oy;
|
||||
if (ir > cr) {
|
||||
dw = w;
|
||||
dh = w / ir;
|
||||
ox = 0;
|
||||
oy = (h - dh) / 2;
|
||||
} else {
|
||||
dh = h;
|
||||
dw = h * ir;
|
||||
ox = (w - dw) / 2;
|
||||
oy = 0;
|
||||
}
|
||||
g.drawImage(img, ox, oy, dw, dh);
|
||||
return c;
|
||||
}
|
||||
|
||||
function sampleAt(data, w, h, x, y) {
|
||||
var xi = x < 0 ? 0 : x >= w ? w - 1 : x | 0;
|
||||
var yi = y < 0 ? 0 : y >= h ? h - 1 : y | 0;
|
||||
var i = (yi * w + xi) * 4;
|
||||
return [data[i], data[i + 1], data[i + 2], data[i + 3]];
|
||||
}
|
||||
|
||||
// Displace every source pixel. Preview and export share this warp.
|
||||
function warpPixels(src, map) {
|
||||
var w = src.width, h = src.height;
|
||||
var s = src.data;
|
||||
var out = new ImageData(w, h);
|
||||
var d = out.data;
|
||||
var x, y, i, nx, ny, p;
|
||||
for (y = 0; y < h; y++) {
|
||||
for (x = 0; x < w; x++) {
|
||||
var m = map(x, y);
|
||||
nx = m[0];
|
||||
ny = m[1];
|
||||
p = sampleAt(s, w, h, nx, ny);
|
||||
i = (y * w + x) * 4;
|
||||
d[i] = p[0];
|
||||
d[i + 1] = p[1];
|
||||
d[i + 2] = p[2];
|
||||
d[i + 3] = p[3];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
DoodleloopApp.prototype.fxRipple = function (ctx, src, t, amt) {
|
||||
var amp = 2 + amt * 10;
|
||||
var warped = warpPixels(src, function (x, y) {
|
||||
var dx = x - SIZE / 2, dy = y - SIZE / 2;
|
||||
var dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
var wave = Math.sin(dist / 14 - t * 6) * amp;
|
||||
var fall = Math.max(0, 1 - dist / (SIZE * 0.72));
|
||||
return [x - (dx / dist) * wave * fall, y - (dy / dist) * wave * fall];
|
||||
});
|
||||
ctx.putImageData(warped, 0, 0);
|
||||
var i;
|
||||
for (i = 0; i < 4; i++) {
|
||||
var radius = ((t * 46 + i * 28) % 180);
|
||||
var alpha = Math.max(0, 1 - radius / 180) * (0.15 + amt * 0.45);
|
||||
ctx.beginPath();
|
||||
ctx.arc(SIZE / 2, SIZE / 2, radius, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = "rgba(24, 72, 140, " + alpha + ")";
|
||||
ctx.lineWidth = 1.5 + amt * 2;
|
||||
ctx.stroke();
|
||||
}
|
||||
};
|
||||
|
||||
DoodleloopApp.prototype.fxSwirl = function (ctx, src, t, amt) {
|
||||
var turns = 0.15 + amt * 0.85;
|
||||
var cx = SIZE / 2, cy = SIZE / 2;
|
||||
var warped = warpPixels(src, function (x, y) {
|
||||
var dx = x - cx, dy = y - cy;
|
||||
var dist = Math.sqrt(dx * dx + dy * dy);
|
||||
var ang = Math.atan2(dy, dx) + Math.sin(t * 1.4) * turns * (1 - dist / (SIZE * 0.75));
|
||||
var breathe = 1 + Math.sin(t * 2 + dist / 40) * amt * 0.04;
|
||||
return [cx + Math.cos(ang) * dist * breathe, cy + Math.sin(ang) * dist * breathe];
|
||||
});
|
||||
ctx.putImageData(warped, 0, 0);
|
||||
};
|
||||
|
||||
DoodleloopApp.prototype.fxBleed = function (ctx, src, t, amt) {
|
||||
var spread = 1 + amt * 6;
|
||||
var warped = warpPixels(src, function (x, y) {
|
||||
var n = Math.sin(x * 0.05 + t * 1.3) * Math.cos(y * 0.04 - t);
|
||||
return [x + n * spread, y + Math.cos(x * 0.03 + t * 0.8) * spread * 0.7];
|
||||
});
|
||||
ctx.putImageData(warped, 0, 0);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.18 + amt * 0.25;
|
||||
ctx.globalCompositeOperation = "multiply";
|
||||
var i;
|
||||
for (i = 0; i < 6; i++) {
|
||||
var sx = (SIZE * 0.2 + Math.sin(t * 0.7 + i) * SIZE * 0.28) | 0;
|
||||
var sy = (SIZE * 0.25 + Math.cos(t * 0.55 + i * 1.3) * SIZE * 0.28) | 0;
|
||||
var p = sampleAt(src.data, SIZE, SIZE, sx, sy);
|
||||
var rad = 28 + amt * 50 + Math.sin(t + i) * 8;
|
||||
var g = ctx.createRadialGradient(sx, sy, 0, sx, sy, rad);
|
||||
g.addColorStop(0, "rgba(" + p[0] + "," + p[1] + "," + p[2] + ",0.9)");
|
||||
g.addColorStop(1, "rgba(" + p[0] + "," + p[1] + "," + p[2] + ",0)");
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, rad, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
DoodleloopApp.prototype.fxBob = function (ctx, src, t, amt) {
|
||||
var strips = 8;
|
||||
var sh = SIZE / strips;
|
||||
var bob = 4 + amt * 16;
|
||||
var i, sy, h, dy, dx;
|
||||
ctx.fillStyle = "#f4efe4";
|
||||
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||
for (i = 0; i < strips; i++) {
|
||||
sy = Math.floor(i * sh);
|
||||
h = Math.floor((i + 1) * sh) - sy;
|
||||
dy = Math.sin(t * 3.2 + i * 0.7) * bob;
|
||||
dx = Math.cos(t * 2.1 + i) * bob * 0.35;
|
||||
ctx.drawImage(this.sourceCanvas, 0, sy, SIZE, h, dx, sy + dy, SIZE, h);
|
||||
}
|
||||
};
|
||||
|
||||
DoodleloopApp.prototype.fxNeon = function (ctx, src, t, amt) {
|
||||
var pulse = 0.55 + 0.45 * Math.sin(t * 5);
|
||||
var lift = (0.15 + amt * 0.55) * pulse;
|
||||
var data = src.data;
|
||||
var out = new ImageData(SIZE, SIZE);
|
||||
var d = out.data;
|
||||
var i;
|
||||
for (i = 0; i < data.length; i += 4) {
|
||||
var r = data[i], g = data[i + 1], b = data[i + 2];
|
||||
var lum = (r * 0.3 + g * 0.59 + b * 0.11) / 255;
|
||||
var edge = lum < 0.72 ? (0.72 - lum) : 0;
|
||||
d[i] = Math.min(255, r + edge * 180 * lift + 40 * lift);
|
||||
d[i + 1] = Math.min(255, g + edge * 40 * lift);
|
||||
d[i + 2] = Math.min(255, b + edge * 220 * lift + 30 * lift);
|
||||
d[i + 3] = data[i + 3];
|
||||
}
|
||||
ctx.putImageData(out, 0, 0);
|
||||
ctx.strokeStyle = "rgba(80, 220, 255, " + (0.25 + pulse * amt * 0.6) + ")";
|
||||
ctx.lineWidth = 3 + amt * 4 * pulse;
|
||||
ctx.strokeRect(18, 18, SIZE - 36, SIZE - 36);
|
||||
};
|
||||
|
||||
DoodleloopApp.prototype.fxSnow = function (ctx, src, t, amt) {
|
||||
ctx.putImageData(src, 0, 0);
|
||||
var count = 24 + Math.round(amt * 46);
|
||||
var i;
|
||||
ctx.fillStyle = "rgba(255,255,255,0.9)";
|
||||
for (i = 0; i < count; i++) {
|
||||
var flake = this.snow[i];
|
||||
var y = (flake.y + t * 42 * flake.speed) % (SIZE + 12) - 6;
|
||||
var x = flake.x + Math.sin(t * 1.4 + i) * (6 + amt * 14);
|
||||
x = ((x % SIZE) + SIZE) % SIZE;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, flake.r + amt, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
};
|
||||
|
||||
DoodleloopApp.prototype.fxFire = function (ctx, src, t, amt) {
|
||||
var heat = 6 + amt * 22;
|
||||
var warped = warpPixels(src, function (x, y) {
|
||||
var rise = (SIZE - y) / SIZE;
|
||||
var flick = Math.sin(x * 0.08 + t * 9) * Math.cos(y * 0.05 - t * 6);
|
||||
return [x + flick * heat * rise, y + Math.abs(flick) * heat * 0.35 * rise];
|
||||
});
|
||||
ctx.putImageData(warped, 0, 0);
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "screen";
|
||||
var x;
|
||||
for (x = 0; x < SIZE; x += 8) {
|
||||
var flick = Math.abs(Math.sin(t * 8 + x * 0.2)) * (10 + amt * 36);
|
||||
var p = sampleAt(src.data, SIZE, SIZE, x, SIZE - 8);
|
||||
var grd = ctx.createLinearGradient(x, SIZE, x, SIZE - flick - 20);
|
||||
grd.addColorStop(0, "rgba(" + Math.min(255, p[0] + 80) + "," + Math.min(255, p[1]) + ",20,0.55)");
|
||||
grd.addColorStop(1, "rgba(255,80,0,0)");
|
||||
ctx.fillStyle = grd;
|
||||
ctx.fillRect(x, SIZE - flick - 20, 8, flick + 20);
|
||||
}
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
DoodleloopApp.prototype.fxBubbles = function (ctx, src, t, amt) {
|
||||
ctx.putImageData(src, 0, 0);
|
||||
var i;
|
||||
for (i = 0; i < this.bubbles.length; i++) {
|
||||
var b = this.bubbles[i];
|
||||
var phase = (t * (1.2 + amt) * b.rise + b.phase) % 8;
|
||||
if (phase > 6.4) continue;
|
||||
var y = SIZE - (phase / 8) * (SIZE + 40);
|
||||
var size = 4 + phase * (2 + amt * 3);
|
||||
var x = b.x + Math.sin(t * 2 + i) * 8;
|
||||
var p = sampleAt(src.data, SIZE, SIZE, x, Math.max(0, y));
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, size, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = "rgba(" + p[0] + "," + p[1] + "," + p[2] + ",0.85)";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
};
|
||||
|
||||
DoodleloopApp.prototype.fxLightning = function (ctx, src, t, amt) {
|
||||
var flash = Math.pow(Math.max(0, Math.sin(t * 7.5)), 18);
|
||||
var gate = Math.sin(t * 1.7) > 0.72 ? 1 : 0;
|
||||
var hit = flash * gate * (0.35 + amt * 0.65);
|
||||
var data = src.data;
|
||||
var out = new ImageData(SIZE, SIZE);
|
||||
var d = out.data;
|
||||
var i;
|
||||
for (i = 0; i < data.length; i += 4) {
|
||||
d[i] = Math.min(255, data[i] + hit * 180);
|
||||
d[i + 1] = Math.min(255, data[i + 1] + hit * 180);
|
||||
d[i + 2] = Math.min(255, data[i + 2] + hit * 210);
|
||||
d[i + 3] = data[i + 3];
|
||||
}
|
||||
ctx.putImageData(out, 0, 0);
|
||||
if (hit > 0.08) {
|
||||
ctx.strokeStyle = "rgba(255,255,255," + Math.min(1, hit * 1.4) + ")";
|
||||
ctx.lineWidth = 2 + amt * 2;
|
||||
ctx.beginPath();
|
||||
var x = SIZE * 0.55, y = 0, step;
|
||||
ctx.moveTo(x, y);
|
||||
for (step = 0; step < 7; step++) {
|
||||
x += Math.sin(t * 3 + step * 2.1) * (18 + amt * 22);
|
||||
y += SIZE / 7;
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
};
|
||||
|
||||
DoodleloopApp.prototype.fxShake = function (ctx, src, t, amt) {
|
||||
var mag = 1 + amt * 7;
|
||||
var sx = Math.round(Math.sin(t * 28) * mag);
|
||||
var sy = Math.round(Math.cos(t * 23) * mag);
|
||||
ctx.clearRect(0, 0, SIZE, SIZE);
|
||||
ctx.putImageData(src, sx, sy);
|
||||
};
|
||||
|
||||
DoodleloopApp.prototype.fxWave = function (ctx, src, t, amt) {
|
||||
var amp = 2 + amt * 12;
|
||||
var warped = warpPixels(src, function (x, y) {
|
||||
var depth = y / SIZE;
|
||||
return [
|
||||
x - Math.sin(y * 0.06 + t * 3) * amp * depth,
|
||||
y - Math.cos(x * 0.04 + t * 2.2) * amp * 0.45 * depth
|
||||
];
|
||||
});
|
||||
ctx.putImageData(warped, 0, 0);
|
||||
};
|
||||
|
||||
DoodleloopApp.prototype.fxSparkle = function (ctx, src, t, amt) {
|
||||
ctx.putImageData(src, 0, 0);
|
||||
var i, n = 12 + Math.round(amt * 24);
|
||||
for (i = 0; i < n; i++) {
|
||||
var s = this.sparkles[i];
|
||||
var tw = Math.max(0, Math.sin(t * 4 + s.phase));
|
||||
if (tw < 0.15) continue;
|
||||
var p = sampleAt(src.data, SIZE, SIZE, s.x, s.y);
|
||||
var rad = 1 + tw * (2 + amt * 4);
|
||||
ctx.fillStyle = "rgba(" + Math.min(255, p[0] + 80) + "," + Math.min(255, p[1] + 80) + "," + Math.min(255, p[2] + 40) + "," + tw + ")";
|
||||
ctx.beginPath();
|
||||
ctx.arc(s.x, s.y, rad, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
};
|
||||
|
||||
function styleName(id) {
|
||||
var i;
|
||||
for (i = 0; i < STYLES.length; i++) if (STYLES[i].id === id) return STYLES[i].name;
|
||||
return id;
|
||||
}
|
||||
|
||||
DoodleloopApp.prototype.captureFrames = function (count, duration) {
|
||||
var frames = [];
|
||||
var i;
|
||||
var off = document.createElement("canvas");
|
||||
off.width = SIZE;
|
||||
off.height = SIZE;
|
||||
var ctx = off.getContext("2d", { willReadFrequently: true });
|
||||
for (i = 0; i < count; i++) {
|
||||
ctx.clearRect(0, 0, SIZE, SIZE);
|
||||
this.renderFrame(ctx, (duration * i) / count, this.motionStyle, this.intensity, this.speed);
|
||||
frames.push(ctx.getImageData(0, 0, SIZE, SIZE));
|
||||
}
|
||||
return frames;
|
||||
};
|
||||
|
||||
DoodleloopApp.prototype.exportGIF = function () {
|
||||
var self = this;
|
||||
if (!this.source || this.exporting) return;
|
||||
this.exporting = true;
|
||||
var btn = document.getElementById("export-btn");
|
||||
var prev = btn ? btn.textContent : "";
|
||||
if (btn) btn.textContent = "Rendering GIF…";
|
||||
this.setStatus("Rendering a GIF from the current drawing and style…");
|
||||
var count = 12;
|
||||
var duration = 1.6;
|
||||
var delay = Math.round((duration / count) * 100);
|
||||
var frames;
|
||||
try {
|
||||
frames = this.captureFrames(count, duration);
|
||||
} catch (err) {
|
||||
this.exporting = false;
|
||||
if (btn) btn.textContent = prev || "Export GIF";
|
||||
this.setStatus("GIF export failed: " + (err && err.message ? err.message : err));
|
||||
this.restart();
|
||||
return;
|
||||
}
|
||||
encodeGif(frames, delay).then(function (bytes) {
|
||||
if (bytes[0] !== 0x47 || bytes[1] !== 0x49 || bytes[2] !== 0x46) {
|
||||
throw new Error("Encoder did not produce a GIF header.");
|
||||
}
|
||||
downloadBlob(new Blob([bytes], { type: "image/gif" }), "doodleloop-" + self.motionStyle + ".gif");
|
||||
self.setStatus("Exported " + frames.length + " frames · " + styleName(self.motionStyle) + " · " + bytes.length + " bytes · ready to text or post");
|
||||
}).catch(function (err) {
|
||||
self.setStatus("GIF export failed: " + (err && err.message ? err.message : err));
|
||||
}).then(function () {
|
||||
self.exporting = false;
|
||||
if (btn) btn.textContent = prev || "Export GIF";
|
||||
self.restart();
|
||||
});
|
||||
};
|
||||
|
||||
DoodleloopApp.prototype.exportWebM = function () {
|
||||
if (!this.source || this.exporting) return;
|
||||
var canvas = this.animationCanvas;
|
||||
var mime = pickWebmMime();
|
||||
if (!mime || typeof canvas.captureStream !== "function" || typeof MediaRecorder === "undefined") {
|
||||
this.setStatus("This browser cannot record WebM. Use Export GIF — that path always runs in-page.");
|
||||
return;
|
||||
}
|
||||
var self = this;
|
||||
this.exporting = true;
|
||||
this.setStatus("Recording WebM from the live canvas…");
|
||||
var stream = canvas.captureStream(12);
|
||||
var rec = new MediaRecorder(stream, { mimeType: mime });
|
||||
var chunks = [];
|
||||
rec.ondataavailable = function (e) { if (e.data && e.data.size) chunks.push(e.data); };
|
||||
rec.onstop = function () {
|
||||
var blob = new Blob(chunks, { type: mime });
|
||||
downloadBlob(blob, "doodleloop-" + self.motionStyle + ".webm");
|
||||
self.setStatus("Exported WebM · " + blob.size + " bytes · ready to text or post");
|
||||
self.exporting = false;
|
||||
};
|
||||
rec.onerror = function () {
|
||||
self.setStatus("WebM recording failed. Export GIF still works.");
|
||||
self.exporting = false;
|
||||
};
|
||||
rec.start();
|
||||
setTimeout(function () {
|
||||
try { rec.stop(); } catch (e) { self.exporting = false; }
|
||||
}, 1800);
|
||||
};
|
||||
|
||||
function pickWebmMime() {
|
||||
if (typeof MediaRecorder === "undefined" || !MediaRecorder.isTypeSupported) return "";
|
||||
var types = ["video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm"];
|
||||
var i;
|
||||
for (i = 0; i < types.length; i++) {
|
||||
if (MediaRecorder.isTypeSupported(types[i])) return types[i];
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function downloadBlob(blob, name) {
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(function () { URL.revokeObjectURL(url); }, 4000);
|
||||
}
|
||||
|
||||
// ---------- GIF89a encoder (LZW, local palettes, Netscape loop) ----------
|
||||
|
||||
function encodeGif(frames, delayCs) {
|
||||
var w = frames[0].width, h = frames[0].height;
|
||||
var parts = [];
|
||||
parts.push(ascii("GIF89a"));
|
||||
parts.push(u16(w), u16(h), bytes([0x80, 0, 0]));
|
||||
parts.push(bytes([0, 0, 0, 255, 255, 255]));
|
||||
parts.push(bytes([0x21, 0xFF, 0x0B]));
|
||||
parts.push(ascii("NETSCAPE2.0"));
|
||||
parts.push(bytes([0x03, 0x01, 0x00, 0x00, 0x00]));
|
||||
var i;
|
||||
var palettes = [];
|
||||
var indexed = [];
|
||||
for (i = 0; i < frames.length; i++) {
|
||||
var q = quantize(frames[i], 64);
|
||||
palettes.push(q.palette);
|
||||
indexed.push(q.index);
|
||||
}
|
||||
for (i = 0; i < frames.length; i++) {
|
||||
parts.push(graphicControl(delayCs));
|
||||
parts.push(imageDescriptor(w, h, palettes[i].length));
|
||||
parts.push(paletteBytes(palettes[i]));
|
||||
parts.push(lzw(indexed[i], lzwMin(palettes[i].length)));
|
||||
}
|
||||
parts.push(bytes([0x3B]));
|
||||
var out = concat(parts);
|
||||
return zipDeflate("doodleloop.gif", out).then(function (zipped) {
|
||||
return unzip(zipped).then(function (back) {
|
||||
if (back.length !== out.length || back[0] !== 0x47 || back[1] !== 0x49 || back[2] !== 0x46) {
|
||||
throw new Error("ZIP round-trip of the GIF did not match.");
|
||||
}
|
||||
return out;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ascii(s) {
|
||||
var a = new Uint8Array(s.length);
|
||||
for (var i = 0; i < s.length; i++) a[i] = s.charCodeAt(i);
|
||||
return a;
|
||||
}
|
||||
function bytes(arr) { return new Uint8Array(arr); }
|
||||
function u16(n) { return bytes([n & 255, (n >> 8) & 255]); }
|
||||
function concat(parts) {
|
||||
var n = 0, i;
|
||||
for (i = 0; i < parts.length; i++) n += parts[i].length;
|
||||
var out = new Uint8Array(n), o = 0;
|
||||
for (i = 0; i < parts.length; i++) { out.set(parts[i], o); o += parts[i].length; }
|
||||
return out;
|
||||
}
|
||||
|
||||
function graphicControl(delayCs) {
|
||||
return bytes([0x21, 0xF9, 0x04, 0x08, delayCs & 255, (delayCs >> 8) & 255, 0x00, 0x00]);
|
||||
}
|
||||
function paletteSizeFlag(n) {
|
||||
var flag = 0;
|
||||
while ((1 << (flag + 1)) < n) flag++;
|
||||
return { flag: flag, size: 1 << (flag + 1) };
|
||||
}
|
||||
function imageDescriptor(w, h, colors) {
|
||||
var pf = paletteSizeFlag(colors);
|
||||
return bytes([
|
||||
0x2C, 0, 0, 0, 0,
|
||||
w & 255, (w >> 8) & 255,
|
||||
h & 255, (h >> 8) & 255,
|
||||
0x80 | pf.flag
|
||||
]);
|
||||
}
|
||||
function paletteBytes(pal) {
|
||||
var pf = paletteSizeFlag(pal.length);
|
||||
var out = new Uint8Array(pf.size * 3);
|
||||
var i;
|
||||
for (i = 0; i < pf.size; i++) {
|
||||
var c = pal[i] || [0, 0, 0];
|
||||
out[i * 3] = c[0];
|
||||
out[i * 3 + 1] = c[1];
|
||||
out[i * 3 + 2] = c[2];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function lzwMin(colors) {
|
||||
var n = 2;
|
||||
while ((1 << n) < colors) n++;
|
||||
return Math.max(2, n);
|
||||
}
|
||||
|
||||
function quantize(imageData, maxColors) {
|
||||
var data = imageData.data;
|
||||
var w = imageData.width, h = imageData.height;
|
||||
var pixels = [];
|
||||
var stride = 8;
|
||||
var i, x, y;
|
||||
for (y = 0; y < h; y += stride) {
|
||||
for (x = 0; x < w; x += stride) {
|
||||
i = (y * w + x) * 4;
|
||||
pixels.push([data[i], data[i + 1], data[i + 2]]);
|
||||
}
|
||||
}
|
||||
var boxes = [pixels];
|
||||
while (boxes.length < maxColors) {
|
||||
var bi = 0, br = -1, b;
|
||||
for (b = 0; b < boxes.length; b++) {
|
||||
var range = boxRange(boxes[b]);
|
||||
if (range > br && boxes[b].length > 1) { br = range; bi = b; }
|
||||
}
|
||||
if (br < 1) break;
|
||||
var split = splitBox(boxes[bi]);
|
||||
boxes.splice(bi, 1, split[0], split[1]);
|
||||
}
|
||||
var palette = [];
|
||||
for (b = 0; b < boxes.length; b++) palette.push(boxAverage(boxes[b]));
|
||||
var index = new Uint8Array(w * h);
|
||||
var px = 0;
|
||||
for (i = 0; i < data.length; i += 4) {
|
||||
index[px++] = nearest(palette, data[i], data[i + 1], data[i + 2]);
|
||||
}
|
||||
return { palette: palette, index: index };
|
||||
}
|
||||
function boxRange(px) {
|
||||
var min = [255, 255, 255], max = [0, 0, 0], i, c, best = 0;
|
||||
for (i = 0; i < px.length; i++) {
|
||||
for (c = 0; c < 3; c++) {
|
||||
if (px[i][c] < min[c]) min[c] = px[i][c];
|
||||
if (px[i][c] > max[c]) max[c] = px[i][c];
|
||||
}
|
||||
}
|
||||
for (c = 0; c < 3; c++) if (max[c] - min[c] > best) best = max[c] - min[c];
|
||||
return best;
|
||||
}
|
||||
function splitBox(px) {
|
||||
var min = [255, 255, 255], max = [0, 0, 0], i, c, ch = 0, span = -1;
|
||||
for (i = 0; i < px.length; i++) {
|
||||
for (c = 0; c < 3; c++) {
|
||||
if (px[i][c] < min[c]) min[c] = px[i][c];
|
||||
if (px[i][c] > max[c]) max[c] = px[i][c];
|
||||
}
|
||||
}
|
||||
for (c = 0; c < 3; c++) if (max[c] - min[c] > span) { span = max[c] - min[c]; ch = c; }
|
||||
px.sort(function (a, b) { return a[ch] - b[ch]; });
|
||||
var mid = px.length >> 1;
|
||||
return [px.slice(0, mid), px.slice(mid)];
|
||||
}
|
||||
function boxAverage(px) {
|
||||
var r = 0, g = 0, b = 0, i, n = px.length || 1;
|
||||
for (i = 0; i < px.length; i++) { r += px[i][0]; g += px[i][1]; b += px[i][2]; }
|
||||
return [(r / n) | 0, (g / n) | 0, (b / n) | 0];
|
||||
}
|
||||
function nearest(pal, r, g, b) {
|
||||
var best = 0, bestD = 1e12, i, dr, dg, db, d;
|
||||
for (i = 0; i < pal.length; i++) {
|
||||
dr = pal[i][0] - r; dg = pal[i][1] - g; db = pal[i][2] - b;
|
||||
d = dr * dr + dg * dg + db * db;
|
||||
if (d < bestD) { bestD = d; best = i; if (!d) break; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function lzw(index, minCodeSize) {
|
||||
var clear = 1 << minCodeSize;
|
||||
var eoi = clear + 1;
|
||||
var codeSize = minCodeSize + 1;
|
||||
var nextCode = eoi + 1;
|
||||
var dict = Object.create(null);
|
||||
var outBits = [];
|
||||
function emit(code) { outBits.push(code, codeSize); }
|
||||
emit(clear);
|
||||
var wKey = String(index[0]);
|
||||
var i, k, wk, prefix;
|
||||
for (i = 1; i < index.length; i++) {
|
||||
k = index[i];
|
||||
wk = wKey + "," + k;
|
||||
if (dict[wk] !== undefined) {
|
||||
wKey = wk;
|
||||
} else {
|
||||
prefix = wKey.indexOf(",") === -1 ? +wKey : dict[wKey];
|
||||
emit(prefix);
|
||||
if (nextCode < 4096) {
|
||||
dict[wk] = nextCode++;
|
||||
if (nextCode === (1 << codeSize) + 1 && codeSize < 12) codeSize++; // the decoder adds each entry one code later
|
||||
} else {
|
||||
emit(clear);
|
||||
dict = Object.create(null);
|
||||
codeSize = minCodeSize + 1;
|
||||
nextCode = eoi + 1;
|
||||
}
|
||||
wKey = String(k);
|
||||
}
|
||||
}
|
||||
emit(wKey.indexOf(",") === -1 ? +wKey : dict[wKey]);
|
||||
if (nextCode === (1 << codeSize) && codeSize < 12) codeSize++; // the decoder's last entry can widen the end code
|
||||
emit(eoi);
|
||||
var acc = 0, nbits = 0, bytesOut = [];
|
||||
for (i = 0; i < outBits.length; i += 2) {
|
||||
acc |= outBits[i] << nbits;
|
||||
nbits += outBits[i + 1];
|
||||
while (nbits >= 8) {
|
||||
bytesOut.push(acc & 255);
|
||||
acc >>>= 8;
|
||||
nbits -= 8;
|
||||
}
|
||||
}
|
||||
if (nbits) bytesOut.push(acc & 255);
|
||||
var packed = [minCodeSize];
|
||||
var o = 0;
|
||||
while (o < bytesOut.length) {
|
||||
var len = Math.min(255, bytesOut.length - o);
|
||||
packed.push(len);
|
||||
for (i = 0; i < len; i++) packed.push(bytesOut[o + i]);
|
||||
o += len;
|
||||
}
|
||||
packed.push(0);
|
||||
return new Uint8Array(packed);
|
||||
}
|
||||
|
||||
function crc32(data) {
|
||||
var table = crc32.table;
|
||||
if (!table) {
|
||||
table = new Uint32Array(256);
|
||||
var n, k, c;
|
||||
for (n = 0; n < 256; n++) {
|
||||
c = n;
|
||||
for (k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
table[n] = c >>> 0;
|
||||
}
|
||||
crc32.table = table;
|
||||
}
|
||||
var crc = 0xFFFFFFFF;
|
||||
for (var i = 0; i < data.length; i++) crc = table[(crc ^ data[i]) & 255] ^ (crc >>> 8);
|
||||
return (crc ^ 0xFFFFFFFF) >>> 0;
|
||||
}
|
||||
function u32le(n) { return bytes([n & 255, (n >> 8) & 255, (n >> 16) & 255, (n >> 24) & 255]); }
|
||||
function u16at(b, o) { return b[o] | (b[o + 1] << 8); }
|
||||
function u32at(b, o) { return (b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] << 24)) >>> 0; }
|
||||
|
||||
function zipDeflate(name, raw) {
|
||||
if (typeof CompressionStream !== "function") {
|
||||
return Promise.reject(new Error("This browser has no CompressionStream."));
|
||||
}
|
||||
var cs = new CompressionStream("deflate-raw");
|
||||
return new Response(new Blob([raw]).stream().pipeThrough(cs)).arrayBuffer().then(function (buf) {
|
||||
return zipWith(name, raw, new Uint8Array(buf), 8);
|
||||
});
|
||||
}
|
||||
function zipWith(name, raw, comp, method) {
|
||||
var nameB = ascii(name);
|
||||
var crc = crc32(raw);
|
||||
var now = new Date();
|
||||
var time = (now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1);
|
||||
var date = ((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate();
|
||||
var local = concat([
|
||||
bytes([0x50, 0x4B, 0x03, 0x04]),
|
||||
u16(20), u16(0), u16(method), u16(time), u16(date),
|
||||
u32le(crc), u32le(comp.length), u32le(raw.length),
|
||||
u16(nameB.length), u16(0),
|
||||
nameB, comp
|
||||
]);
|
||||
var central = concat([
|
||||
bytes([0x50, 0x4B, 0x01, 0x02]),
|
||||
u16(20), u16(20), u16(0), u16(method), u16(time), u16(date),
|
||||
u32le(crc), u32le(comp.length), u32le(raw.length),
|
||||
u16(nameB.length), u16(0), u16(0), u16(0), u16(0), u32le(0), u32le(0),
|
||||
nameB
|
||||
]);
|
||||
var eocd = concat([
|
||||
bytes([0x50, 0x4B, 0x05, 0x06]),
|
||||
u16(0), u16(0), u16(1), u16(1),
|
||||
u32le(central.length), u32le(local.length), u16(0)
|
||||
]);
|
||||
return concat([local, central, eocd]);
|
||||
}
|
||||
|
||||
function unzip(zip) {
|
||||
var eocd = -1, i;
|
||||
for (i = zip.length - 22; i >= Math.max(0, zip.length - 22 - 65535); i--) {
|
||||
if (u32at(zip, i) === 0x06054b50) { eocd = i; break; }
|
||||
}
|
||||
if (eocd < 0) return Promise.reject(new Error("ZIP has no end-of-central-directory."));
|
||||
var cd = u32at(zip, eocd + 16);
|
||||
if (u32at(zip, cd) !== 0x02014b50) return Promise.reject(new Error("ZIP central directory missing."));
|
||||
var method = u16at(zip, cd + 10);
|
||||
var compSize = u32at(zip, cd + 20);
|
||||
var localOff = u32at(zip, cd + 42);
|
||||
if (u32at(zip, localOff) !== 0x04034b50) return Promise.reject(new Error("ZIP local header missing."));
|
||||
var nlen = u16at(zip, localOff + 26);
|
||||
var elen = u16at(zip, localOff + 28);
|
||||
var comp = zip.subarray(localOff + 30 + nlen + elen, localOff + 30 + nlen + elen + compSize);
|
||||
if (method === 0) return Promise.resolve(comp.slice());
|
||||
if (method !== 8) return Promise.reject(new Error("Unsupported ZIP method " + method));
|
||||
if (typeof DecompressionStream !== "function") {
|
||||
return Promise.reject(new Error("This browser has no DecompressionStream."));
|
||||
}
|
||||
var ds = new DecompressionStream("deflate-raw");
|
||||
return new Response(new Blob([comp]).stream().pipeThrough(ds)).arrayBuffer().then(function (buf) {
|
||||
return new Uint8Array(buf);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var app = new DoodleloopApp();
|
||||
window.doodleloopApp = app;
|
||||
app.init().catch(function (err) {
|
||||
app.setStatus(err && err.message ? err.message : String(err));
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -1,157 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Doodleloop - Animate Your Drawings</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="header-content">
|
||||
<h1>Doodleloop</h1>
|
||||
<p class="tagline">Turn a photo of a drawing into a looping animated GIF in under a minute</p>
|
||||
<a href="https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&title=Early+access%3A+Doodleloop&venture=doodleloop&plan=Studio+%E2%80%94+Founding+%28early+access%29" target="_blank" rel="noopener" class="cta">Get early access</a>
|
||||
<p class="note">Opens a public GitHub issue. No email, no payment.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="try">
|
||||
<div class="product-container">
|
||||
<div class="canvas-area">
|
||||
<div class="canvas-wrapper">
|
||||
<canvas id="original-canvas"></canvas>
|
||||
<canvas id="animation-canvas"></canvas>
|
||||
</div>
|
||||
<p id="loop-status" class="loop-status">Loading the sample drawing through the same local canvas path as a photo you drop…</p>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="drop-zone" id="drop-zone">
|
||||
<p>Drag & drop your drawing here<br><small>or click to browse — processed locally, nothing leaves the device</small></p>
|
||||
<input type="file" id="file-input" accept="image/*" hidden>
|
||||
</div>
|
||||
|
||||
<button id="sample-btn" class="sample-button">Use Sample</button>
|
||||
|
||||
<div class="control-group">
|
||||
<label for="motion-style">Motion Style</label>
|
||||
<select id="motion-style">
|
||||
<option value="ink-ripple">Ink Ripple</option>
|
||||
<option value="van-gogh-swirl">Van Gogh Swirl</option>
|
||||
<option value="watercolor-bleed">Watercolor Bleed</option>
|
||||
<option value="paper-cut-bob">Paper-Cut Bob</option>
|
||||
<option value="neon-pulse">Neon Pulse</option>
|
||||
<option value="falling-snow">Falling Snow</option>
|
||||
<option value="fire-flicker">Fire Flicker</option>
|
||||
<option value="bubble-pop">Bubble Pop</option>
|
||||
<option value="lightning-flash">Lightning Flash</option>
|
||||
<option value="earthquake-shake">Earthquake Shake</option>
|
||||
<option value="ocean-wave">Ocean Wave</option>
|
||||
<option value="magic-sparkle">Magic Sparkle</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="slider-control">
|
||||
<label for="intensity-slider">Intensity</label>
|
||||
<input type="range" id="intensity-slider" min="0" max="100" value="50">
|
||||
</div>
|
||||
|
||||
<div class="slider-control">
|
||||
<label for="speed-slider">Speed</label>
|
||||
<input type="range" id="speed-slider" min="0" max="100" value="50">
|
||||
</div>
|
||||
|
||||
<button id="export-btn" class="export-button">Export GIF</button>
|
||||
<p class="secondary-links"><a href="#" id="export-webm">WebM optional</a> · <a href="#sample-gallery">Sample gallery</a></p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sample-gallery" class="sample-gallery">
|
||||
<p>Sample gallery — tap to run the same local path as a dropped photo.</p>
|
||||
<button type="button" class="gallery-sample" id="gallery-sample" aria-label="Play the preloaded sample drawing">
|
||||
<img src="sample.jpg" alt="Sample kid drawing of a house, tree, and sun" width="96" height="96">
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="how-it-works">
|
||||
<h2>How it works</h2>
|
||||
<ol>
|
||||
<li>Drop in a drawing or photo</li>
|
||||
<li>Pick a motion style</li>
|
||||
<li>Export a looping animated GIF</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section id="why-this-exists">
|
||||
<h2>Why this exists</h2>
|
||||
<p>Doodleloop rides these repos, trending on GitHub this week:</p>
|
||||
<ul>
|
||||
<li><a href="https://github.com/alchaincyf/huashu-art-motion" target="_blank" rel="noopener">alchaincyf/huashu-art-motion</a> (3,381 stars)</li>
|
||||
<li><a href="https://github.com/wheresryan22/anatomy" target="_blank" rel="noopener">wheresryan22/anatomy</a> (3,270 stars)</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="pricing">
|
||||
<h2>Pricing</h2>
|
||||
<p>Nothing is charged today.</p>
|
||||
|
||||
<div class="pricing-tiers">
|
||||
<div class="tier">
|
||||
<h3>Free ($0)</h3>
|
||||
<p>for anyone with a photo of a drawing</p>
|
||||
<ul>
|
||||
<li>local in-browser processing (nothing uploaded)</li>
|
||||
<li>a dozen motion styles</li>
|
||||
<li>intensity and speed sliders</li>
|
||||
<li>instant preview</li>
|
||||
<li>GIF export with optional WebM</li>
|
||||
</ul>
|
||||
<a href="#try" class="tier-button">Use it free</a>
|
||||
</div>
|
||||
|
||||
<div class="tier">
|
||||
<h3>Studio — Founding (early access) ($29 one-time)</h3>
|
||||
<p>for families and teachers who want to direct the motion, not just pick a style</p>
|
||||
<ul>
|
||||
<li>custom motion paths</li>
|
||||
<li>HD/4K loop export</li>
|
||||
<li>print-ready stills</li>
|
||||
<li>saved projects</li>
|
||||
<li>founding-price locked in for life</li>
|
||||
</ul>
|
||||
<a href="https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&title=Early+access%3A+Doodleloop&venture=doodleloop&plan=Studio+%E2%80%94+Founding+%28early+access%29" target="_blank" rel="noopener" class="tier-button">Join early access</a>
|
||||
</div>
|
||||
|
||||
<div class="tier">
|
||||
<h3>Studio — Classroom (early access) ($59 one-time)</h3>
|
||||
<p>for teachers animating a whole class set of drawings</p>
|
||||
<ul>
|
||||
<li>everything in Studio — Founding plus</li>
|
||||
<li>batch-animate a sketchbook or classroom set</li>
|
||||
<li>class-size export queue</li>
|
||||
</ul>
|
||||
<a href="https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&title=Early+access%3A+Doodleloop&venture=doodleloop&plan=Studio+%E2%80%94+Classroom+%28early+access%29" target="_blank" rel="noopener" class="tier-button">Join early access</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="limits">
|
||||
<h2>Limits</h2>
|
||||
<ul>
|
||||
<li>No custom motion paths yet (coming in Studio)</li>
|
||||
<li>Only exports GIF (WebM optional)</li>
|
||||
<li>No saved projects</li>
|
||||
<li>No batch processing</li>
|
||||
<li>HD/4K export not available in free version</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<p>Built by <a href="https://teahub.xyz/teahubxyz/time" target="_blank" rel="noopener">Time, an agent-run venture studio on GitHub</a></p>
|
||||
<p>Want the next version? <a href="https://teahub.xyz/teahubxyz/time/issues/17" target="_blank" rel="noopener">👍 the signal issue</a></p>
|
||||
</footer>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 238 KiB |