Initial backup of root workspace files
Glue files not covered by subproject repos: top-level docs, logo, keysat-design-system, and crosscheck tests. Subproject folders are gitignored (each has its own Gitea remote).
@@ -0,0 +1,28 @@
|
||||
# Keep the Docker build context small even though workdir is the parent
|
||||
# directory. The Keysat Dockerfile only COPYs from `licensing-service/`, so
|
||||
# everything else in this folder is dead weight in the build context.
|
||||
|
||||
# Sibling projects we don't need for the Keysat daemon build
|
||||
activate-license-template/
|
||||
licensing-client-rust/
|
||||
licensing-client-ts/
|
||||
tests/
|
||||
|
||||
# Wrapper's own build output & heavy dirs (the wrapper is only needed at
|
||||
# build time indirectly — start-cli handles it separately)
|
||||
licensing-service-startos/node_modules/
|
||||
licensing-service-startos/javascript/
|
||||
licensing-service-startos/*.s9pk
|
||||
|
||||
# Source-only directories we don't need inside the container
|
||||
licensing-service/target/
|
||||
licensing-service/docs/
|
||||
|
||||
# Docs + assets at the root of Licensing/
|
||||
*.md
|
||||
logo.png
|
||||
|
||||
# Git + editor
|
||||
.git/
|
||||
.DS_Store
|
||||
*.bak
|
||||
@@ -0,0 +1,15 @@
|
||||
# Subprojects — each is its own git repo backed up to its own Gitea remote
|
||||
/activate-license-template/
|
||||
/keysat-docs/
|
||||
/keysat-registry-landing/
|
||||
/keysat-xyz-landing/
|
||||
/licensing-client-go/
|
||||
/licensing-client-python/
|
||||
/licensing-client-rust/
|
||||
/licensing-client-ts/
|
||||
/licensing-service-startos/
|
||||
/plans/
|
||||
|
||||
# Local machine config / junk
|
||||
/.claude/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,99 @@
|
||||
# Building and sideloading Keysat
|
||||
|
||||
This repo ships three components that you'll bundle into a single installable `.s9pk`:
|
||||
|
||||
- `licensing-service/` — the Rust daemon (`keysat` binary).
|
||||
- `licensing-service-startos/` — the StartOS 0.4.0.x wrapper (TypeScript manifest + actions + Dockerfile).
|
||||
- `licensing-client-rust/` and `licensing-client-ts/` — SDKs for downstream software. Not bundled into the `.s9pk`; published separately when you cut a release.
|
||||
|
||||
The Rust service's on-disk folder name is still `licensing-service/` for continuity, but the crate, binary, and operator-visible branding are all **Keysat** now.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A working **StartOS 0.4.0.x development environment** on a Linux or macOS workstation. Follow [docs.start9.com](https://docs.start9.com) if you haven't set this up yet.
|
||||
2. `start-cli` installed on your PATH, with `~/.startos/developer.key.pem` initialised (`start-cli init`).
|
||||
3. **Node 20+** (for the StartOS SDK, TypeScript).
|
||||
4. **Docker + buildx** (for multi-arch image builds).
|
||||
5. Network reachability from your workstation to your Start9 server (LAN or Tor — whatever you usually use to `start-cli install`).
|
||||
6. A running **BTCPay Server** on the same Start9 server (Keysat depends on it).
|
||||
|
||||
You do **not** need Rust locally — the Dockerfile pulls a pinned `rust:1.75-slim-bookworm` image.
|
||||
|
||||
## One-time setup
|
||||
|
||||
### 1. Fetch the shared build logic
|
||||
|
||||
Every 0.4.0.x wrapper includes `s9pk.mk`, which is maintained by the Start9 team. Fetch the current copy once:
|
||||
|
||||
```bash
|
||||
cd licensing-service-startos
|
||||
curl -o s9pk.mk https://raw.githubusercontent.com/Start9Labs/hello-world-startos/master/s9pk.mk
|
||||
```
|
||||
|
||||
The `Makefile` `include s9pk.mk` line will error out until that file is in place.
|
||||
|
||||
### 2. Install TypeScript deps
|
||||
|
||||
```bash
|
||||
cd licensing-service-startos
|
||||
npm install
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
From `licensing-service-startos/`:
|
||||
|
||||
```bash
|
||||
# Build for all supported architectures (x86_64 + aarch64)
|
||||
make
|
||||
|
||||
# Or just the architecture your Start9 box runs
|
||||
make arm # Raspberry Pi / Apple Silicon
|
||||
make x86 # x86_64 StartOS server
|
||||
```
|
||||
|
||||
The resulting artifact lands in `licensing-service-startos/*.s9pk` — a single file you can sideload.
|
||||
|
||||
## Sideloading
|
||||
|
||||
```bash
|
||||
cd licensing-service-startos
|
||||
make install
|
||||
```
|
||||
|
||||
This uses your `~/.startos/developer.key.pem` to push the package to the Start9 server it's registered against. First-run prompts for the Start9 dashboard password; subsequent installs reuse the cached session.
|
||||
|
||||
If you prefer to upload the `.s9pk` through the StartOS dashboard UI, copy the file off your workstation and drag it into **Sideload package** from the dashboard.
|
||||
|
||||
## First-boot checklist
|
||||
|
||||
Once installed, open **Keysat** in your StartOS dashboard and run these actions in order:
|
||||
|
||||
1. **Set operator name** — your display name, shown on the public homepage.
|
||||
2. **Connect BTCPay** — opens a BTCPay consent page in your browser. Click Authorize; Keysat auto-detects your store and registers its inbound webhook.
|
||||
3. **Check BTCPay connection** — confirms the authorize completed and the webhook is live.
|
||||
4. **Create product** — one per thing you plan to sell.
|
||||
5. **Create policy** — at least one per product, slugged `default`. Sets the shape of keys issued through the public purchase flow.
|
||||
|
||||
At this point your buyers can `POST /v1/purchase` against your Keysat URL, pay via BTCPay, and poll back for their license key.
|
||||
|
||||
## Testing it end-to-end
|
||||
|
||||
Keysat ships with a cross-check test suite that independently signs license keys with a Python reference implementation and replays them through the Rust and TypeScript SDK parsers. See `tests/crosscheck/README.md`.
|
||||
|
||||
The simplest smoke test against a live install:
|
||||
|
||||
```bash
|
||||
# On your workstation, once the service is running on StartOS:
|
||||
curl https://<your-keysat-url>/v1/pubkey
|
||||
curl https://<your-keysat-url>/healthz
|
||||
```
|
||||
|
||||
The first returns the Ed25519 public key you'll bake into downstream software. The second should just return `{"ok":true}`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"KEYSAT_ADMIN_API_KEY must be at least 32 characters"** — shouldn't happen from StartOS (the wrapper generates the key), but if you're running locally, `openssl rand -hex 32` gives a valid value.
|
||||
- **"BTCPay not yet configured — purchases will return 503"** — run **Connect BTCPay** from the StartOS dashboard.
|
||||
- **Rebuilds don't reflect Rust source changes** — Docker layer caching is aggressive; `make clean` before `make` to force a full rebuild.
|
||||
- **Existing installs using `LICENSING_*` env vars** — the daemon still reads those as a fallback for one release cycle. New installs set `KEYSAT_*` directly.
|
||||
@@ -0,0 +1,97 @@
|
||||
# How Keysat works
|
||||
|
||||
This is a plain-English walkthrough of what Keysat actually does, written for people who do not want to read code. It follows two people, Alice and Bob. Alice has written a piece of software she wants to sell. Bob wants to buy it.
|
||||
|
||||
## The cast
|
||||
|
||||
Alice runs her software on her own Start9 server at home. She has two services there: her **BTCPay Server** (a self-hosted Bitcoin payment processor), and a new one she just installed called **licensing-service** — the thing this whole project is about. Her licensing-service is single-tenant: it sells *her* software only, not anyone else's. If a fellow developer wants the same functionality, they install their own copy on their own Start9, just like Alice did.
|
||||
|
||||
Bob is a customer. He may or may not own a Start9. Whether he does affects the experience, not the mechanics — we'll get to that.
|
||||
|
||||
## What a license actually is
|
||||
|
||||
At the center of this system is a short string of characters. It looks like this:
|
||||
|
||||
```
|
||||
LIC1-AEAW6RVE6YGS6SRIW2VD5D57N4UPA...-FV56FI7ZTB5GIFQHIPQ35QVVE5AO5...
|
||||
```
|
||||
|
||||
That string is the whole license. There is nothing else — no account, no login, no subscription server to reach. Think of it as a cryptographically signed receipt. Anyone who has Alice's public signing key (which she publishes openly) can look at the string and tell, with mathematical certainty, three things: who signed it, what it licenses, and whether anyone has tampered with it. The underlying cryptography is Ed25519, the same family used by SSH, GitHub commits, and Tor.
|
||||
|
||||
This matters because it means two things can happen without any server being online. First, Bob's computer can verify the key is real at every startup without phoning home — Alice does not need to run anything on the day Bob wants to use her software. Second, if Alice gets hit by a bus, every customer's copy keeps working forever.
|
||||
|
||||
## Alice's day-1 setup
|
||||
|
||||
Alice installs licensing-service on her Start9 from the marketplace. On first boot it generates its own signing keypair, and SQLite database, and gives her a random admin API key through a StartOS action called "Show admin credentials." She copies that API key into her password manager.
|
||||
|
||||
Then she clicks "Connect BTCPay." This is the part everybody expected to be painful and isn't. Instead of asking her to go to BTCPay, generate an API key with the right permissions, register a webhook, copy a webhook secret, and paste all of those things back into licensing-service — which is the flow every BTCPay integration has historically demanded — the action returns a single URL. Alice opens it. BTCPay shows her a consent page listing exactly the permissions being requested. She clicks "Authorize." BTCPay sends the approval straight back to licensing-service, which automatically detects her store, picks a webhook secret out of a hat, and registers the webhook on her behalf. Zero pasting. This works because BTCPay's own authorize endpoint (`/api-keys/authorize`) was designed for OAuth-style handshakes and almost no integration uses it.
|
||||
|
||||
Last step: Alice creates a product. She picks a slug, like `bitcoin-ticker-pro`, and a price in satoshis. Now she can sell.
|
||||
|
||||
## Bob buys a license
|
||||
|
||||
The buying experience depends on what Bob is running.
|
||||
|
||||
### Bob has a Start9 and Alice's software is a Start9 package
|
||||
|
||||
This is the nicest path. Bob installs Alice's package from whatever marketplace he uses. When it comes up, the package dashboard shows a "License" section with four actions: Buy license, Finish license purchase, Check license status, Deactivate license. These actions come from the drop-in template in `activate-license-template/`, which Alice added to her package when she built it.
|
||||
|
||||
Bob clicks "Buy license." His Start9 talks to Alice's licensing-service, which opens a BTCPay invoice and returns a checkout URL. Bob opens that URL, pays with Lightning (or on-chain if he prefers), and closes the tab. A minute later he clicks "Finish license purchase." His Start9 polls Alice's service, sees the payment settled, pulls down the license key, verifies its signature locally, and stores it in his package's own database. Next time Alice's software boots on Bob's machine, it picks up the key from the local store and launches.
|
||||
|
||||
Nothing was copied or pasted. Bob never saw the license string. The key got captured as a side effect of payment.
|
||||
|
||||
### Bob is on a regular computer, and Alice's software is not a Start9 package
|
||||
|
||||
This is the more common case for today's world. Bob goes to Alice's website, clicks "Buy," and is taken to a BTCPay checkout. He pays. BTCPay shows a success page that includes the license string — at that point it's the only way he could get it, short of an email. Bob copies the string and pastes it into Alice's app's settings dialog. Alice's app (which has embedded her public key at build time) verifies the signature and unlocks. From then on, no network is required.
|
||||
|
||||
### Bob never paid — Alice gave him a comp
|
||||
|
||||
Alice's licensing-service has an admin action called "Issue license manually," which hands her a signed license for free, with an optional note field ("comped for friend," "press reviewer," and so on). Useful for reviewers, test machines, friends, and early-access customers.
|
||||
|
||||
## What stops Bob from sharing the key with a friend
|
||||
|
||||
This is where the design has to be honest. No licensing system reliably prevents sharing. Determined people will always figure something out. What good licensing does is make casual sharing annoying enough that it doesn't happen often, while keeping the honest customer's experience painless. We have two tools.
|
||||
|
||||
The first is **fingerprint binding**. When Alice's app first runs, it collects a stable identifier for the machine — something like the machine-id on Linux, or a hash of hardware specs on other platforms. It sends that fingerprint to Alice's licensing-service the first time it validates a key online. The service stores the fingerprint alongside the license row. Trust-on-first-use, like SSH. The key can't be moved to a second machine and work online — when the second machine checks in, the service notices a mismatch and returns "rejected." The second machine also can't pretend to be the first, because the fingerprint is hashed on-device and that hash gets baked into the signed part of the key at issuance. Forging it would require Alice's private signing key, which lives only on Alice's Start9.
|
||||
|
||||
The gap in that story: if Bob's friend never goes online, nothing stops him. The signed key still verifies, and signature verification is all that runs at boot. A motivated offline-only user can share keys. In practice this is a small fraction of the market, and the design treats it as acceptable collateral for never holding legitimate customers hostage when their internet drops.
|
||||
|
||||
The second tool is **revocation**. If Alice learns a specific key has been leaked — she sees it posted on a forum, or a customer asks for a refund — she clicks "Revoke license" on her admin dashboard and marks the key as dead. The next time anyone using that key does an online check, they get rejected. Offline users running on stolen keys are unaffected, but the key becomes unsellable going forward.
|
||||
|
||||
These two tools, used together, make key sharing an unpleasant experience for the sharer (fingerprint mismatches), put a ceiling on the damage a leaked key can do (revocation), and leave legitimate users completely unaffected. That is the best the industry has, and it's what reputable commercial licensing systems like FastSpring's or Paddle's do too.
|
||||
|
||||
## What if Alice's server is offline
|
||||
|
||||
The offline path is the default path. When Bob's copy of Alice's app starts up, it runs the signature check against Alice's public key, which is embedded at build time, and if the signature is valid the app launches. The licensing-service does not need to be up. Alice's Start9 can be powered off for a year and no legitimate customer is inconvenienced.
|
||||
|
||||
The online path is layered on top as an opportunistic enhancement: every so often, when the network is up, the app calls Alice's licensing-service's `/v1/validate` endpoint. That endpoint applies revocation and fingerprint binding. If the service is unreachable, nothing breaks — the app treats that as "can't tell" and continues on the last known offline-good status.
|
||||
|
||||
This is the correct tradeoff for paid software that has already been paid for. If the licensing-service is a hard dependency, Alice's going offline for a weekend locks every customer out, which is the failure mode that gives online-licensing its bad reputation.
|
||||
|
||||
## What if Bob wipes his machine
|
||||
|
||||
Two cases.
|
||||
|
||||
If Bob has a Start9, his Start9 backup includes his package's store, which includes his license key. Restoring the backup restores the license. He does not need to contact Alice. Keys are, in effect, Start9-backup-compatible.
|
||||
|
||||
If Bob is on a regular computer, he kept the license key string somewhere — ideally in his password manager, since it is functionally a password. He pastes it back into Alice's app. Done.
|
||||
|
||||
If Bob lost the key entirely, Alice can look him up in her licensing-service admin by invoice id or email and reissue (or he can buy again, depending on Alice's policy). The service keeps every license in its database indefinitely, so "I lost my key" is a five-second fix from Alice's end.
|
||||
|
||||
## What prevents Alice from being ripped off at the payment layer
|
||||
|
||||
BTCPay is a real non-custodial payment processor. When Bob pays, the bitcoin goes straight to Alice's wallet (an xpub or a Lightning node she controls), not to a middleman. BTCPay then notifies licensing-service via a signed webhook that a specific invoice settled. The signed-webhook part matters — without a signature, a malicious actor could fire fake "invoice settled" events and trick licensing-service into issuing free keys. The webhook secret, which was generated automatically during the authorize flow and is never exposed to Alice or any HTTP client, is the shared signing key. Every incoming webhook is rejected if its HMAC-SHA256 signature doesn't verify in constant time.
|
||||
|
||||
Webhook deliveries are also not the only signal. A background task inside licensing-service, every 60 seconds, lists every invoice that is still in a pending state and polls BTCPay directly for its current status. If BTCPay says "settled" but the service didn't see a webhook for it (maybe BTCPay's webhook delivery dropped, or the service was down), the background task catches up and issues the license. This closes the window where a customer has paid but the licensing-service missed the event.
|
||||
|
||||
## What this setup is *not*
|
||||
|
||||
Not a subscription service. A license here is perpetual — one payment, one key, forever. If Alice wants subscriptions she would need to add expiry to the payload and a renewal flow; the payload format has a `flags` byte and a future version bump to support that, but it is not in scope for the current version.
|
||||
|
||||
Not a DRM system. It does not prevent someone with a debugger from patching out the license check. Nothing running on the user's machine can, by construction — the user controls their CPU. This is a licensing system for reasonable people who want to pay.
|
||||
|
||||
Not multi-tenant. Each Alice runs her own licensing-service. Two Alices cannot share one server to sell two different products. That simplification is deliberate — it keeps the data model small, makes disaster recovery obvious ("back up one SQLite file"), and keeps Alice in control of the signing key. If someone wants a SaaS version that hosts many sellers, that's a different product.
|
||||
|
||||
## The bet
|
||||
|
||||
The bet of this whole project is that once the rough edges above are smooth — one-click BTCPay, drop-in Start9 actions, offline verification by default, proper webhook signing, automatic reconciliation — a developer looking to monetize their Start9 package will choose to install this licensing-service instead of writing their own. Not because it's unique, but because it is finished. The competition is other developers writing their own from scratch, and the first-mover advantage is that every hour they spend building theirs is an hour Alice has already spent on her product.
|
||||
@@ -0,0 +1,221 @@
|
||||
# Keysat master keypair — generation, storage, and import
|
||||
|
||||
This document covers the keypair that signs every Keysat self-license — the
|
||||
"Keysat-licenses-Keysat" trust root. It is the single most security-critical
|
||||
piece of cryptographic material in the entire project. Anyone with the
|
||||
private key can mint Keysat licenses indefinitely. Treat it accordingly.
|
||||
|
||||
## What this keypair is
|
||||
|
||||
An Ed25519 keypair generated by Grant on his laptop. The **public** half is
|
||||
embedded in the Keysat daemon source code at `licensing-service/src/license_self.rs`
|
||||
in the `TRUST_ROOT_PUBKEY_PEM` constant. Any Keysat install with that source
|
||||
embedded will accept license keys signed by the corresponding private half,
|
||||
and reject everything else.
|
||||
|
||||
The **private** half is held offline and only used to mint Keysat licenses
|
||||
(eventually via a "master Keysat" instance that imports the keypair).
|
||||
|
||||
## How it was generated
|
||||
|
||||
```
|
||||
openssl genpkey -algorithm Ed25519 -out keysat-master-private.pem
|
||||
openssl pkey -in keysat-master-private.pem -pubout -out keysat-master-public.pem
|
||||
```
|
||||
|
||||
These are the same commands that ran. They produce two files:
|
||||
|
||||
- `keysat-master-private.pem` — the private key, ~120 bytes
|
||||
- `keysat-master-public.pem` — the public key, ~120 bytes
|
||||
|
||||
## What's already done
|
||||
|
||||
✅ Public key embedded in `licensing-service/src/license_self.rs` as `TRUST_ROOT_PUBKEY_PEM`.
|
||||
The current value is:
|
||||
|
||||
```
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAgsromMy4osMJplX1rY0fd4ouS6wfkm/vfeY2gXEQHkA=
|
||||
-----END PUBLIC KEY-----
|
||||
```
|
||||
|
||||
If that block ever needs to change (e.g. if the private key leaks and you have
|
||||
to rotate), edit `TRUST_ROOT_PUBKEY_PEM` and ship a new package version. Note
|
||||
that all previously-issued licenses become unverifiable on the new package —
|
||||
plan rotations carefully.
|
||||
|
||||
## What you need to do
|
||||
|
||||
### Right now: secure the private key
|
||||
|
||||
The private-key file is sitting in your home folder (`~/keysat-master-private.pem`).
|
||||
Do all of the following before anything else:
|
||||
|
||||
1. **Make a paper backup.** Open the file in TextEdit, print it on paper (one
|
||||
sheet, three lines of base64). Store the printout in a fireproof safe or
|
||||
safe-deposit box. Test that the printed copy is legible — small fonts and
|
||||
inkjet bleed are common gotchas.
|
||||
|
||||
2. **Make a digital backup in something secure.** Recommended in rough order:
|
||||
|
||||
- 1Password / Bitwarden secure note (attach the .pem file). Synced across
|
||||
your devices, encrypted at rest.
|
||||
- Encrypted USB drive (VeraCrypt or APFS-encrypted disk image), kept offline.
|
||||
- GPG-encrypted backup uploaded to a personal cloud bucket.
|
||||
|
||||
3. **Delete the plaintext file from your laptop's home folder** once backed up.
|
||||
|
||||
```
|
||||
srm ~/keysat-master-private.pem # macOS
|
||||
shred -u ~/keysat-master-private.pem # Linux
|
||||
```
|
||||
|
||||
Don't just `rm` it — that leaves the bytes recoverable on most filesystems.
|
||||
|
||||
### When you're ready: stand up a master Keysat instance
|
||||
|
||||
The master Keysat is a separate Keysat install whose only job is to mint
|
||||
licenses for the Keysat package itself. It runs on its own Start9 (or wherever)
|
||||
and uses the private key you generated as its issuer key.
|
||||
|
||||
The keypair is stored in the daemon's SQLite database (under the `server_keys`
|
||||
table), not as a file on disk. To bootstrap the master instance, use the
|
||||
admin-only `POST /v1/admin/import-issuer-key` endpoint, added in v0.1.0:15
|
||||
specifically for this scenario.
|
||||
|
||||
Steps:
|
||||
|
||||
1. Install the Keysat package on a fresh Start9 (or a separate StartOS account
|
||||
on the same Start9, if possible). Standard install flow. The daemon will
|
||||
generate a throwaway issuer keypair on first boot — that's expected; you'll
|
||||
replace it in step 3.
|
||||
|
||||
2. Grab the admin API key for this fresh instance: StartOS dashboard → Keysat
|
||||
service → **Actions → Show admin API key**. Copy the 64-hex-character key.
|
||||
|
||||
3. From your laptop (the one with `keysat-master-private.pem`), POST the PEM
|
||||
to the master Keysat's import-issuer-key endpoint:
|
||||
|
||||
```
|
||||
ADMIN_KEY="paste-the-admin-api-key-here"
|
||||
MASTER_KEYSAT="https://your-master-keysat.example" # or LAN URL like https://your-mdns.local:port
|
||||
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer $ADMIN_KEY" \
|
||||
-H "Content-Type: application/x-pem-file" \
|
||||
--data-binary @/path/to/keysat-master-private.pem \
|
||||
"$MASTER_KEYSAT/v1/admin/import-issuer-key"
|
||||
```
|
||||
|
||||
Expected response:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"public_key_pem": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n",
|
||||
"restart_required": true,
|
||||
"message": "Issuer key imported. Restart the Keysat service for the new key to take effect..."
|
||||
}
|
||||
```
|
||||
|
||||
The endpoint refuses if any licenses have already been issued by this
|
||||
Keysat — a safety guard against accidentally invalidating customer
|
||||
keys. Since this is a freshly-installed master instance, it should
|
||||
succeed.
|
||||
|
||||
4. **Stop and start the Keysat service** from the StartOS dashboard. The
|
||||
in-memory keypair gets re-loaded from the DB on startup, picking up
|
||||
the imported key. Confirm in the logs that the public key now matches
|
||||
your `keysat-master-public.pem`.
|
||||
|
||||
5. In the master Keysat's admin UI, define a product called "Keysat" with your
|
||||
intended pricing (e.g. 50,000 sats standard, 250,000 sats patron).
|
||||
|
||||
6. Define a `default` policy on the Keysat product. Perpetual single-seat is a
|
||||
reasonable default. Optionally add entitlements: `["patron"]` for a Patron
|
||||
tier.
|
||||
|
||||
7. The master instance is now ready. Self-redeem a free license for your own
|
||||
non-master Keysat installs (use `free_license` discount codes for
|
||||
bootstrapping).
|
||||
|
||||
#### Alternative: SSH if you prefer file-system access
|
||||
|
||||
If you'd rather not use the import endpoint, you can SSH into the Start9 host
|
||||
and edit the SQLite database directly. Less recommended (more error-prone), but
|
||||
documented for completeness:
|
||||
|
||||
```
|
||||
# Enable SSH in StartOS Settings → SSH, add your laptop's pubkey, then:
|
||||
ssh start9@<your-mdns>.local
|
||||
# find the keysat data volume
|
||||
sudo find / -name "*.db" -path "*keysat*" 2>/dev/null
|
||||
# edit server_keys row 1 with sqlite3
|
||||
```
|
||||
|
||||
This route requires you to UPSERT the right PEM strings into the
|
||||
`server_keys` table manually. Stick with the curl-based import endpoint
|
||||
unless you specifically need SSH access for some other reason.
|
||||
|
||||
#### Why this isn't a StartOS button
|
||||
|
||||
Putting an "Import master signing key" button in every Keysat operator's
|
||||
StartOS Actions tab would clutter the UX for the 95% of operators who don't
|
||||
need it (their auto-generated issuer key is exactly what they want). The
|
||||
import-issuer-key endpoint is admin-only, invisible by default, and called
|
||||
exactly once during master setup.
|
||||
|
||||
### When 100 free first-user licenses are needed
|
||||
|
||||
In the master instance's admin UI:
|
||||
|
||||
1. Go to **Discount codes → Create new code**.
|
||||
2. Set kind `free_license`, max uses 100, no expiry.
|
||||
3. Pick a memorable code: `EARLY100`, `FOUNDERS100`, etc.
|
||||
4. Distribute the code via your launch announcement / Twitter / Nostr / mailing
|
||||
list. First 100 users redeem at `https://registry.keysat.xyz` (or wherever
|
||||
the buy page is hosted by the master instance).
|
||||
|
||||
## Threat model and rotation plan
|
||||
|
||||
**If the private key leaks:**
|
||||
|
||||
- An attacker can mint Keysat licenses indefinitely.
|
||||
- Existing customers' licenses are unaffected — the leak doesn't retroactively
|
||||
invalidate signatures.
|
||||
- Mitigation: rotate. Generate a fresh keypair, edit `TRUST_ROOT_PUBKEY_PEM`,
|
||||
ship a new package version, re-issue licenses to existing customers, deprecate
|
||||
the leaked key in your customer comms.
|
||||
|
||||
**If the private key is destroyed (and no backup):**
|
||||
|
||||
- You can't issue any new licenses ever.
|
||||
- Existing customer licenses keep working.
|
||||
- Mitigation: rotation as above. Painful but recoverable.
|
||||
|
||||
This is why the paper backup matters more than anything. Cloud sync is great
|
||||
for daily use, but a paper copy in a safe is the resilience floor.
|
||||
|
||||
## Future: rolling rotation (v0.3+)
|
||||
|
||||
The current model is hard-cut: one public key, one private key, one rotation
|
||||
event invalidates everything. v0.3 will land a rolling-rotation flow:
|
||||
|
||||
- Daemon embeds *two* trust-root pubkeys — primary and standby.
|
||||
- During rotation, the daemon accepts licenses signed by either.
|
||||
- After all customers have re-licensed under the new key, the old pubkey is
|
||||
retired in the next package version.
|
||||
|
||||
This makes rotation graceful instead of catastrophic. Until that ships,
|
||||
treat the private key as if losing it would mean re-licensing every customer
|
||||
(because it would).
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Action | Command |
|
||||
|---|---|
|
||||
| Generate keypair (already done) | `openssl genpkey -algorithm Ed25519 -out keysat-master-private.pem && openssl pkey -in keysat-master-private.pem -pubout -out keysat-master-public.pem` |
|
||||
| Inspect public key | `openssl pkey -in keysat-master-public.pem -pubin -text -noout` |
|
||||
| Inspect private key (don't normally do this) | `openssl pkey -in keysat-master-private.pem -text -noout` |
|
||||
| Securely delete plaintext | macOS: `srm <path>` · Linux: `shred -u <path>` |
|
||||
| Verify which key is embedded in daemon | `grep -A4 TRUST_ROOT_PUBKEY_PEM licensing-service-startos/licensing-service/src/license_self.rs` |
|
||||
@@ -0,0 +1,266 @@
|
||||
# Porting Keysat SDK to New Languages — v0.2 Roadmap
|
||||
|
||||
This document is a working spec for adding first-class language support
|
||||
beyond the v0.1 Rust + TypeScript SDKs. Use it as the contributor brief
|
||||
when you (or someone you hire) sit down to write the Python SDK, the Go
|
||||
SDK, etc.
|
||||
|
||||
**Scope:** v0.1 ships with `licensing-client` (Rust crate) and
|
||||
`@keysat/licensing-client` (npm). Anyone integrating Keysat into software
|
||||
written in another language has to either write their own thin verifier
|
||||
against the documented wire format, or wait for an official SDK. v0.2's
|
||||
goal is to remove that friction for the languages where most paid
|
||||
software actually ships.
|
||||
|
||||
---
|
||||
|
||||
## Language priorities for v0.2
|
||||
|
||||
In rough order of how often each comes up in indie / small-team paid
|
||||
software, and how complete the Ed25519 + base32 ecosystem is:
|
||||
|
||||
| Priority | Language | Rationale | Crypto status |
|
||||
|----------|-------------|------------------------------------------------------------------------|---------------|
|
||||
| 1 | Python | Server-side software, CLIs, ML tools, scientific software. | `cryptography` mature, Ed25519 native |
|
||||
| 2 | Go | Backend services, CLIs, infrastructure tools. | `crypto/ed25519` in stdlib |
|
||||
| 3 | C# / .NET | Windows desktop apps, games (Unity), enterprise tools. | `NSec` or `BouncyCastle` |
|
||||
| 4 | Swift | macOS + iOS apps. Largest paid-app market. | `CryptoKit` Ed25519 native (iOS 13+) |
|
||||
| 5 | Java/Kotlin | Android apps, enterprise software. | `BouncyCastle` |
|
||||
| 6 | C++ | Native games, audio/video tools. | `libsodium` |
|
||||
|
||||
Picking just two as the first batch: **Python** (already has the reference
|
||||
signer code, already proven to interoperate) and **Go** (most-requested
|
||||
language for self-hosted server software).
|
||||
|
||||
---
|
||||
|
||||
## Wire format — the single source of truth
|
||||
|
||||
Every SDK must implement the exact byte layout already documented and
|
||||
test-fixtured in this repo. Do not invent a new layout.
|
||||
|
||||
**Authoritative source files.** Read all three before writing a line of
|
||||
code:
|
||||
|
||||
- `licensing-service/src/crypto/mod.rs` — Rust impl, comment-heavy.
|
||||
Defines `LicensePayload`, encoding, decoding, and the v1↔v2 boundary.
|
||||
- `licensing-client-ts/src/key.ts` — second independent impl in TS.
|
||||
- `tests/crosscheck/reference_signer.py` — third independent impl in
|
||||
Python. Uses `cryptography` rather than `pynacl` — different
|
||||
underlying primitive impl, so agreement is meaningful.
|
||||
|
||||
**Format summary** (read the source for byte-exact details):
|
||||
|
||||
- Key string shape: `LIC1-<base32-payload>-<base32-signature>` with
|
||||
Crockford base32 alphabet (uppercase, no padding, with the standard
|
||||
Crockford alphabet substitutions: I↔1, L↔1, O↔0).
|
||||
- Signature: Ed25519 over the raw payload bytes.
|
||||
- Payload: a fixed-prefix binary structure with `version | flags |
|
||||
product_id (UUID, 16 bytes) | license_id (UUID, 16 bytes) | issued_at
|
||||
(i64 unix seconds) | expires_at (i64 unix seconds, 0 means perpetual)
|
||||
| fingerprint_hash (32 bytes, all zeros if unbound)`. v2 adds a
|
||||
variable-length tail of UTF-8 entitlements. v1 (legacy) is fixed at
|
||||
74 bytes with no entitlements.
|
||||
- Flags: bit 0 = `FINGERPRINT_BOUND`, bit 1 = `TRIAL`. Reserve all
|
||||
other bits for future expansion; SDKs must preserve unknown flags
|
||||
on parse rather than rejecting.
|
||||
- Fingerprint: client-supplied raw string, hashed by the SDK to SHA-256
|
||||
before being sent to the server. The server stores only the hash.
|
||||
|
||||
If the v0.1 Rust source and this doc ever disagree on a byte, the Rust
|
||||
source wins and this doc gets fixed.
|
||||
|
||||
---
|
||||
|
||||
## Functional parity matrix — what every SDK must do
|
||||
|
||||
Ordered roughly by integration value:
|
||||
|
||||
| Feature | Required | Notes |
|
||||
|----------------------------------|----------|----------------------------------------------------------------|
|
||||
| Parse a `LIC1-...-...` key | Required | Both v1 and v2 layouts. |
|
||||
| Verify Ed25519 signature offline | Required | Given the issuer's PEM-encoded public key. |
|
||||
| Detect expiry (`isExpiredAt`) | Required | Pure-function helper, no clock dep — caller supplies time. |
|
||||
| Detect tamper / bad signature | Required | Distinct error type from "expired" or "wrong product". |
|
||||
| Check entitlements | Required | Boolean helper given a key + entitlement slug. |
|
||||
| Compute fingerprint hash | Required | SHA-256 hex; raw input never leaves the host (server-side hashed too). |
|
||||
| HTTP `validate` call | Required | Returns reason on rejection (`revoked`, `fingerprint_mismatch`, `not_found`, `bad_signature`, `product_mismatch`). |
|
||||
| HTTP `machines/heartbeat` | Recommended | Updates `last_heartbeat_at` on the bound machine. |
|
||||
| HTTP `machines/activate` | Recommended | Explicit seat activation. |
|
||||
| HTTP `machines/deactivate` | Recommended | Free a seat. |
|
||||
| HTTP `purchase/start` + poll | Recommended | Drives the BTCPay flow from inside the app. Optional but very common. |
|
||||
| HTTP `redeem` (free codes) | Recommended | One-shot redemption of `free_license` codes. |
|
||||
| Public key from PEM | Required | Should accept the exact PEM string `GET /v1/pubkey` returns. |
|
||||
| Public key from raw bytes | Optional | Convenience for compiled-in keys. |
|
||||
| Browser/server agnostic | Where possible | TS SDK runs in both. Python & Go are server-only by nature; that's fine. |
|
||||
|
||||
Two boolean rules of thumb:
|
||||
|
||||
1. **Network failures must not propagate as license-invalid.** Every SDK's
|
||||
online methods must distinguish "could not reach server" from "server
|
||||
rejected the key." Callers will treat the first as "status unknown,
|
||||
fall back to offline check" and the second as "actually invalid."
|
||||
2. **Offline check must be the default code path.** All SDKs put the
|
||||
offline verifier on the easy path. Online validation is opt-in. This
|
||||
is what protects the seller's customers from the seller's downtime.
|
||||
|
||||
---
|
||||
|
||||
## Cross-language test vectors
|
||||
|
||||
`tests/crosscheck/vector.json` is the canonical fixture. Every new SDK
|
||||
must pass every fixture in there before being released. The current
|
||||
contents (read the file for the source-of-truth values):
|
||||
|
||||
- `v1` — legacy fixed-74 layout, fingerprint-bound. Tests the legacy
|
||||
parser branch.
|
||||
- `v2` — trial key, fingerprint-bound, explicit expiry, two
|
||||
entitlements. Tests the variable-length tail parser.
|
||||
- `v2_perpetual_unbound` — the common case for a paid purchase. v2, no
|
||||
expiry, no binding, no entitlements.
|
||||
|
||||
For every new SDK, `tests/crosscheck/` should grow a new
|
||||
`run_<lang>.<ext>` runner that loads `vector.json` and asserts:
|
||||
|
||||
1. Each key parses cleanly and field-for-field matches the fixture.
|
||||
2. Each key's signature verifies against the fixture's PEM public key.
|
||||
3. Tampering with one byte of the key string produces a verification
|
||||
error.
|
||||
4. Fingerprint binding succeeds with the matching fingerprint and fails
|
||||
with a different one.
|
||||
5. Entitlement lookup returns true for declared entitlements and false
|
||||
for absent ones.
|
||||
6. `isExpiredAt` flips at the documented boundary.
|
||||
7. `hashFingerprint("hello")` matches Python's
|
||||
`hashlib.sha256("hello".encode()).hexdigest()`.
|
||||
|
||||
If a runner can pass all seven, the SDK is wire-compatible.
|
||||
|
||||
---
|
||||
|
||||
## Per-language porting checklist
|
||||
|
||||
Apply this checklist when starting a new SDK in language `L`. It's
|
||||
intentionally generic — adapt to L's conventions.
|
||||
|
||||
### Phase 0 — read
|
||||
|
||||
- [ ] Read this document end to end.
|
||||
- [ ] Read `licensing-service/src/crypto/mod.rs` (the byte-exact source).
|
||||
- [ ] Read `licensing-client-rust/src/lib.rs` and
|
||||
`licensing-client-ts/src/key.ts` to see two existing impls.
|
||||
- [ ] Read `tests/crosscheck/reference_signer.py` for a third.
|
||||
- [ ] Run the existing TypeScript cross-check (`tests/crosscheck/run_ts.mjs`)
|
||||
to confirm the fixtures in your local clone are valid.
|
||||
|
||||
### Phase 1 — offline verifier (the bulk of the value)
|
||||
|
||||
- [ ] Pick / vendor an Ed25519 verifier library for L. Use the most
|
||||
mainstream option (stdlib if L has it, otherwise the most-used
|
||||
package). Avoid hand-rolling crypto.
|
||||
- [ ] Pick / vendor a Crockford base32 decoder. If none exists in L's
|
||||
ecosystem, write ~30 lines (the alphabet is fixed; decoding is a
|
||||
simple table lookup).
|
||||
- [ ] Implement `PublicKey::from_pem` (or idiomatic equivalent) that
|
||||
accepts the exact string `GET /v1/pubkey` returns.
|
||||
- [ ] Implement `parseLicenseKey(string) -> LicenseKey` that handles
|
||||
both v1 and v2 layouts, preserves unknown flag bits, and rejects
|
||||
malformed inputs with a clear error type.
|
||||
- [ ] Implement `Verifier::verify(key) -> VerifyOk | Error` that does
|
||||
base32 decode → split sig from payload → ed25519 verify → return
|
||||
parsed payload.
|
||||
- [ ] Implement `isExpiredAt(key, time)` and `hasEntitlement(key,
|
||||
slug)` as pure helpers.
|
||||
- [ ] Implement `hashFingerprint(string) -> hex string`.
|
||||
- [ ] Pass fixtures 1–6 in `vector.json` via a new
|
||||
`tests/crosscheck/run_<lang>.<ext>` runner.
|
||||
|
||||
### Phase 2 — online client (optional features)
|
||||
|
||||
- [ ] Decide on an HTTP library that's idiomatic for L. Avoid pinning to
|
||||
something exotic — most users will already have a default.
|
||||
- [ ] Implement `Client::new(baseUrl)`. Strip trailing slashes
|
||||
defensively.
|
||||
- [ ] Implement `Client::baseUrl()` getter (we use this in v0.1 to
|
||||
compute redirect URLs).
|
||||
- [ ] Implement `Client::fetchPubkeyPem()`.
|
||||
- [ ] Implement `Client::validate(key, productSlug, fingerprint)`.
|
||||
Distinguish network errors from server-side rejections in the
|
||||
return type.
|
||||
- [ ] Implement `Client::heartbeat(key, fingerprint)`.
|
||||
- [ ] Implement `Client::activate(key, fingerprint, opts)`.
|
||||
- [ ] Implement `Client::deactivate(key, fingerprint, reason?)`.
|
||||
- [ ] Implement `Client::startPurchase(productSlug, opts)` — opts
|
||||
includes optional `code`, `buyerEmail`, `redirectUrl`.
|
||||
- [ ] Implement `Client::pollPurchase(invoiceId)`.
|
||||
- [ ] Implement `Client::waitForLicense(invoiceId, opts?)` — convenience
|
||||
polling loop. Throw on terminal states (`expired`, `invalid`).
|
||||
- [ ] Implement `Client::redeemFreeLicense(productSlug, code, opts?)` —
|
||||
the no-BTCPay path for `free_license` codes.
|
||||
|
||||
### Phase 3 — packaging + release
|
||||
|
||||
- [ ] Pick a package name. Keep it close to `keysat-licensing-client`
|
||||
modulo language conventions.
|
||||
- [ ] Pin compatible language version. Document it (Python ≥ 3.10, Go
|
||||
≥ 1.21, etc.).
|
||||
- [ ] Write a README mirroring the structure of
|
||||
`licensing-client-ts/README.md` (5-line offline check + 10-line
|
||||
online check + purchase flow + browser usage if applicable).
|
||||
- [ ] Add an `examples/` directory with at least an offline-verify
|
||||
example and an online-validate example.
|
||||
- [ ] Hook the SDK's tests into CI in this monorepo so a v1 wire-format
|
||||
change automatically breaks every SDK.
|
||||
- [ ] Publish to the language registry (PyPI / pkg.go.dev / Maven /
|
||||
SwiftPM / NuGet).
|
||||
|
||||
---
|
||||
|
||||
## Distribution & naming
|
||||
|
||||
| Language | Package name | Registry |
|
||||
|-------------|-----------------------------|------------------|
|
||||
| Rust | `licensing-client` | crates.io |
|
||||
| TypeScript | `@keysat/licensing-client` | npm |
|
||||
| Python | `keysat-licensing-client` | PyPI |
|
||||
| Go | `github.com/keysat-xyz/keysat-go/client` | pkg.go.dev (Go modules use the import path) |
|
||||
| Java/Kotlin | `xyz.keysat.licensing-client` | Maven Central |
|
||||
| Swift | `KeysatLicensingClient` | Swift Package Manager (Git tag) |
|
||||
| C# / .NET | `Keysat.LicensingClient` | NuGet |
|
||||
| C++ | `keysat-licensing-client` | vcpkg / Conan |
|
||||
|
||||
---
|
||||
|
||||
## Versioning
|
||||
|
||||
Every SDK starts at `0.1.0` and tracks the wire-format version
|
||||
independently of its own SemVer. Wire-format breaking changes (a new
|
||||
required field in the payload, e.g.) bump every SDK's minor version
|
||||
simultaneously. Wire-compatible additions (a new optional flag bit) bump
|
||||
the patch version.
|
||||
|
||||
A wire-format change requires:
|
||||
|
||||
1. Update `licensing-service/src/crypto/mod.rs` first.
|
||||
2. Add new fixtures to `tests/crosscheck/vector.json`.
|
||||
3. Update every SDK to pass the new fixtures.
|
||||
4. Release coordinated minor versions.
|
||||
|
||||
This is a real coordination tax. The mitigation is to keep wire-format
|
||||
changes infrequent and additive.
|
||||
|
||||
---
|
||||
|
||||
## Maintenance burden — be honest about it
|
||||
|
||||
Each SDK is a long-tail liability. Every breaking change in the wire
|
||||
format is N times the work, where N is the number of SDKs. Every CVE in
|
||||
a transitive HTTP or crypto dependency is a release per language. Be
|
||||
realistic about how many SDKs the team can credibly maintain. Two or
|
||||
three first-class is plausibly forever; six or seven becomes a
|
||||
not-getting-around-to-it problem.
|
||||
|
||||
The escape valve: keep the wire format documented well enough that an
|
||||
abandoned SDK is straightforward to fork or replace. If the wire-format
|
||||
section of this doc is ever harder to read than the code, we've
|
||||
regressed and need to fix it.
|
||||
@@ -0,0 +1,156 @@
|
||||
# Product Branding & Custom Domains — Design (v0.3.0)
|
||||
|
||||
**Status:** approved 2026-05-08 by Grant. Slotted as **v0.3.0:0** (branding tokens) and **v0.3.0:1** (custom-domain routing). Lands after recurring-subs admin UI ships in v0.2.x.
|
||||
|
||||
## Problem
|
||||
|
||||
Operators may sell multiple software products through one Keysat instance. Each product likely has its own marketing site and brand identity. Today, every product's `/buy/<slug>` page renders with Keysat branding (logo, colors, copy). Buyers landing on a Keysat purchase page from a product's marketing site experience a context break — the brand they came from disappears.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Each product's `/buy/<slug>` page renders with that product's branding (logo, colors, support email, footer copy, links to terms/privacy).
|
||||
2. Operators can route a custom-domain path (e.g. `product1.com/product1/buy`) to their Keysat instance via StartTunnel and have the page render branded for the right product.
|
||||
3. The feature is **Pro-tier gated** — it joins recurring subs, multi-currency, and Lightning tipping as Pro features. Free tier sees Keysat default branding only.
|
||||
|
||||
## Non-goals (v1)
|
||||
|
||||
- **No custom CSS or HTML.** Theme tokens (colors, logo URL, copy strings) only. Custom layouts are a "wait until someone asks" problem.
|
||||
- **Keysat does not host the marketing landing.** Operators host `product1.com` themselves (Vercel, Cloudflare Pages, whatever). Keysat only owns the `/buy/...` path that StartTunnel forwards.
|
||||
- **No bare-domain rendering.** `product1.com` is not Keysat's. Only `product1.com/product1/buy` (or whatever path the operator chooses) maps to Keysat.
|
||||
- **No per-product asset upload in v1.** Logos and favicons are URL references — operators host the assets themselves. Asset upload + CDN-style serving is a v0.3.x follow-up if asked for.
|
||||
|
||||
## Routing model (revised 2026-05-08)
|
||||
|
||||
Operator's setup, per product:
|
||||
1. They run their marketing site at `product1.com` (NOT on Keysat). Keysat never serves the operator's apex.
|
||||
2. They create a `licensing.product1.com` **subdomain** (or any subdomain pattern they prefer) and point a StartTunnel forward at their Keysat instance. One forward per product. All forwards land on the same Keysat instance — they're distinguished by the `Host` header.
|
||||
3. The buy link on the marketing site reads `https://licensing.product1.com/product1/buy`. Clean, brand-consistent, and the URL itself signals "billing/checkout" to the buyer.
|
||||
|
||||
### Why subdomains instead of path prefixes on the apex
|
||||
|
||||
- Operator keeps `product1.com` fully under their own control (marketing CMS, A/B tests, blog, etc.). Only `licensing.product1.com` is delegated to the Keysat tunnel — much lower coordination cost, lower blast radius if the tunnel goes down.
|
||||
- The URL is self-documenting. A buyer hovering over the link sees `licensing.product1.com`, which is recognisable as a billing/checkout subdomain — higher trust signal than a path on the apex.
|
||||
- DNS / TLS lifecycle is independent of the marketing site. The operator can rotate, retire, or swap-Keysat-instance for the subdomain without touching their apex.
|
||||
|
||||
### URL shape inside Keysat
|
||||
|
||||
We keep the existing path: **`/buy/<slug>`**. The buy link from the operator's marketing site reads `https://licensing.product1.com/buy/product1` — same path Keysat already serves, just on a branded subdomain. No route alias, no reserved-slug churn, full back-compat with any marketing site that's already linking to `/buy/<slug>` on the bare Keysat onion.
|
||||
|
||||
The branding tokens get applied at render time based on the product the path resolved to. Host header is not consulted for the v1 routing — the slug in the path carries the product identity.
|
||||
|
||||
### Bare-host root: out of scope
|
||||
|
||||
We do NOT render the product buy page at `https://licensing.product1.com/` (bare subdomain root). Confirmed 2026-05-08: operators always link to the explicit `/buy/<slug>` URL from their marketing site. Skipping bare-host means no Host-header middleware, no `licensing_subdomain` JSON field, no per-host product lookup table — meaningful complexity savings.
|
||||
|
||||
A buyer who manually types just `licensing.product1.com` lands on the standard Keysat root response (operator-name banner, public-key endpoint pointer). Acceptable: this is a vanishingly rare case, and the operator's marketing site is the canonical entry point.
|
||||
|
||||
## Data model
|
||||
|
||||
Add one column to `products`:
|
||||
|
||||
```sql
|
||||
-- migration 0014_product_branding.sql (v0.3.0)
|
||||
ALTER TABLE products ADD COLUMN branding TEXT; -- nullable JSON blob
|
||||
```
|
||||
|
||||
Stored shape (validated server-side on write, sane defaults applied at render):
|
||||
|
||||
```json
|
||||
{
|
||||
"display_name": "ProductOne", // overrides products.name on the buy page only
|
||||
"tagline": "...", // short subtitle
|
||||
"logo_url": "https://.../logo.png",
|
||||
"favicon_url": "https://.../favicon.ico",
|
||||
"primary_color": "#1a73e8", // hex; used for buttons, accents
|
||||
"accent_color": "#ff6b00", // hex; secondary highlights
|
||||
"background_color": "#ffffff", // hex; page bg
|
||||
"text_color": "#111827", // hex; main copy
|
||||
"theme": "light", // "light" | "dark" | "auto"
|
||||
"support_email": "support@product1.com",
|
||||
"footer_text": "© 2026 ProductOne, LLC.",
|
||||
"terms_url": "https://product1.com/terms",
|
||||
"privacy_url": "https://product1.com/privacy",
|
||||
"canonical_buy_url": "https://product1.com/product1/buy" // used in receipts/emails
|
||||
}
|
||||
```
|
||||
|
||||
All fields optional. Missing fields fall back to Keysat defaults. Unknown fields are silently dropped on write (forward-compat).
|
||||
|
||||
## API surface
|
||||
|
||||
**Public** (no auth — buyers, SDKs):
|
||||
- `GET /v1/public/products/<slug>` already returns product metadata. **Extend** the response to include the `branding` object so SDKs can render branded purchase prompts (e.g. an in-app "Buy Pro" button styled to match the product).
|
||||
|
||||
**Admin** (Bearer + admin key):
|
||||
- `PATCH /v1/admin/products/<id>` already exists for product mutation; add `branding` to the accepted body. Validation: hex colors regex, URL parsing on `*_url` fields, length caps on copy strings, theme enum check.
|
||||
- Optional convenience: `PUT /v1/admin/products/<id>/branding` taking just the branding JSON. Adds nothing the PATCH doesn't, but cleaner separation in the admin UI.
|
||||
|
||||
## Admin UI
|
||||
|
||||
New "Branding" tab on the product editor (`admin/index.html`). Inputs:
|
||||
- Display name override
|
||||
- Tagline
|
||||
- Logo URL + small preview
|
||||
- Favicon URL
|
||||
- Color pickers (primary, accent, background, text) with live preview swatches
|
||||
- Theme dropdown (light / dark / auto)
|
||||
- Support email
|
||||
- Footer text (textarea, length-capped)
|
||||
- Terms URL, Privacy URL, Canonical Buy URL
|
||||
- "Preview" button — opens `/buy/<slug>?preview=1` in a new tab with the unsaved tokens applied via query params (so admins can preview before saving)
|
||||
|
||||
Pro-tier gate: tab visible to all, but `Save` is disabled with a "Pro tier required" tooltip on Free. The check uses the same `self_tier` plumbing as recurring/multi-currency/tipping.
|
||||
|
||||
## Render path
|
||||
|
||||
`/buy/<slug>` template currently reads from a fixed `templates/buy.html`. v0.3.0 changes:
|
||||
1. Load product → load `branding` JSON.
|
||||
2. Pass branding tokens into the template context with Keysat fallbacks for any missing field.
|
||||
3. Inject CSS variables in a `<style>` block at the top of `<head>`:
|
||||
```css
|
||||
:root {
|
||||
--keysat-primary: <primary_color or #f7931a>;
|
||||
--keysat-accent: <accent_color or #1a73e8>;
|
||||
--keysat-bg: <background_color or #ffffff>;
|
||||
--keysat-text: <text_color or #111827>;
|
||||
}
|
||||
```
|
||||
4. Existing buy.html CSS uses these variables (one-time refactor of hardcoded colors → vars).
|
||||
5. Logo/favicon: render `<img src="{{logo_url or '/static/keysat-logo.svg'}}">` and `<link rel="icon" href="{{favicon_url or '/static/favicon.ico'}}">`.
|
||||
6. Theme = "dark" toggles a `data-theme="dark"` attribute on `<html>`; CSS uses `[data-theme="dark"]` selectors to swap palettes. "auto" uses `prefers-color-scheme`.
|
||||
7. Support email + footer text + terms/privacy links render in the page footer, hiding the row entirely if all are unset.
|
||||
|
||||
The recovery page (`/recover`) is product-scoped on submit — once a product is selected, the same branding pass applies to the success/error rendering. Phase 1 may keep `/recover` Keysat-branded if simpler.
|
||||
|
||||
## Phasing
|
||||
|
||||
**Phase 1 (v0.3.0:0) — the whole feature, one PR:**
|
||||
- Migration 0014 (bumped from 0013 — tier-upgrades schema took 0013 first; see TIER_UPGRADES_DESIGN.md) — `products.branding` column (nullable JSON blob).
|
||||
- Server-side branding validator + Pro-tier gate.
|
||||
- API: extend `GET /v1/public/products/<slug>` and `PATCH /v1/admin/products/<id>` to accept/return `branding`.
|
||||
- Admin UI: branding tab on the product editor + live-preview button.
|
||||
- Buy page: refactor hardcoded colors to CSS vars, inject branded tokens at render. The existing `/buy/<slug>` route is unchanged — only the styling/copy varies per product.
|
||||
- Tests: validator unit tests, integration test for branded buy-page render, integration test for the Pro-tier gate.
|
||||
|
||||
No Phase 2 in scope right now. If operators eventually ask for fancier surfaces (bare-host rendering, custom CSS, hosted assets, canonical-URL rendering in receipts), each of those is a discrete v0.3.x or v0.4.x follow-up.
|
||||
|
||||
**Possible follow-up (v0.3.x) — asset upload, only if operators ask:**
|
||||
- Upload endpoint `POST /v1/admin/products/<id>/assets/{logo,favicon}`.
|
||||
- Local storage under `${data_dir}/product-assets/<product_id>/...`.
|
||||
- Public route `GET /assets/products/<id>/<file>` serves them with cache headers.
|
||||
- Branding fields automatically populated with the served URL.
|
||||
|
||||
Skip until requested — most operators have a CDN or static-hosting setup already; URL references cover them.
|
||||
|
||||
## Open questions (defer until Phase 1 implementation)
|
||||
|
||||
- **Per-policy branding overrides?** Different tiers might want different colors (Pro = gold accent, Free = blue). Probably no — keep it product-level. Revisit if requested.
|
||||
- **Email branding.** Receipts and recovery emails currently use Keysat copy. Phase 1 should at minimum use `branding.display_name` and `branding.support_email` in the rendered email so receipts say "ProductOne" not "Keysat". Logo embedding in HTML email is a follow-up.
|
||||
- **OG / social-share metadata.** `<meta property="og:image">`, `<meta property="og:title">` should pull from branding so links posted to Twitter/Discord render with the right preview. Trivial add — include in Phase 1.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Custom CSS, custom JavaScript, custom HTML blocks.
|
||||
- Per-product session/cookie domains (sessions are admin-only, scoped to admin UI).
|
||||
- White-labeling the admin UI itself (operator-facing, no buyer ever sees it).
|
||||
- A/B testing or experiment framework on the buy page.
|
||||
@@ -0,0 +1,188 @@
|
||||
# Make the SDKs actually installable by external developers + LLMs
|
||||
|
||||
The KEYSAT_INTEGRATION.md doc tells consumers to `npm install
|
||||
@keysat/licensing-client`, `pip install keysat-licensing-client`, and
|
||||
`cargo add keysat-licensing-client` (or git fallbacks). Today none of
|
||||
those paths work cleanly — all three SDK repos are private on GitHub,
|
||||
and none of the packages are published to their respective registries.
|
||||
|
||||
This doc is the punch list to fix that. Everything below is a one-time
|
||||
setup. Once done, integration on the consumer side becomes a single
|
||||
install command.
|
||||
|
||||
## TL;DR
|
||||
|
||||
For each SDK repo, do **one of two** things:
|
||||
|
||||
- **Option A (best): publish to the package registry.** Repo can stay
|
||||
private; the published artifact is what consumers install.
|
||||
- **Option B (good): make the GitHub repo public.** Consumers install
|
||||
via `npm install github:...` / `pip install git+...` / cargo `git =
|
||||
"..."`. Free, no account setup required.
|
||||
|
||||
Option A is the right long-term answer (faster installs, fewer cross-
|
||||
service auth issues). Option B unblocks the LLM-wiring test cleanly
|
||||
without commiting to a registry yet.
|
||||
|
||||
## TS SDK — `@keysat/licensing-client`
|
||||
|
||||
### Option A: publish to npm
|
||||
|
||||
```sh
|
||||
cd ~/path/to/Licensing/licensing-client-ts
|
||||
|
||||
# 1) Login (one-time per machine).
|
||||
npm login
|
||||
|
||||
# 2) Verify the build works locally before publishing.
|
||||
npm install
|
||||
npm run build
|
||||
npm test
|
||||
|
||||
# 3) Publish. The first publish under a new scope needs --access public.
|
||||
npm publish --access public
|
||||
|
||||
# 4) Sanity check.
|
||||
npm view @keysat/licensing-client
|
||||
```
|
||||
|
||||
The `prepublishOnly` script in `package.json` already builds + tests
|
||||
before publish; the line above just runs it explicitly first to catch
|
||||
any failures locally rather than on the registry side.
|
||||
|
||||
### Option B: make the GitHub repo public
|
||||
|
||||
On github.com → `keysat-xyz/keysat-client-ts` → Settings → General →
|
||||
Danger Zone → Change repository visibility → Public.
|
||||
|
||||
A `prepare` script was added to `package.json` (this version) so git-
|
||||
installs build `dist/` automatically. Without that script, consumers
|
||||
got `Cannot find module './dist/index.cjs'` at import time. Confirm
|
||||
your local `package.json` has both:
|
||||
|
||||
```json
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
||||
"test": "vitest run",
|
||||
"prepare": "npm run build",
|
||||
"prepublishOnly": "npm run build && npm test"
|
||||
}
|
||||
```
|
||||
|
||||
After making the repo public, verify in a clean dir:
|
||||
|
||||
```sh
|
||||
mkdir /tmp/keysat-test && cd /tmp/keysat-test
|
||||
npm init -y
|
||||
npm install github:keysat-xyz/keysat-client-ts
|
||||
node -e "console.log(require('@keysat/licensing-client'))"
|
||||
```
|
||||
|
||||
Should print the module exports without errors.
|
||||
|
||||
## Python SDK — `keysat-licensing-client`
|
||||
|
||||
### Option A: publish to PyPI
|
||||
|
||||
```sh
|
||||
cd ~/path/to/Licensing/licensing-client-python
|
||||
|
||||
# 1) Build.
|
||||
python3 -m pip install --upgrade build twine
|
||||
python3 -m build
|
||||
|
||||
# 2) Login (one-time): create an API token at https://pypi.org/manage/account/token/
|
||||
# Save it to ~/.pypirc or pass via env.
|
||||
|
||||
# 3) Upload.
|
||||
python3 -m twine upload dist/*
|
||||
```
|
||||
|
||||
### Option B: make the GitHub repo public
|
||||
|
||||
On github.com → `keysat-xyz/keysat-client-python` → Settings → Public.
|
||||
No additional changes needed — pip installs pure-Python packages from
|
||||
git directly.
|
||||
|
||||
Verify:
|
||||
|
||||
```sh
|
||||
python3 -m venv /tmp/keysat-py-test && . /tmp/keysat-py-test/bin/activate
|
||||
pip install git+https://github.com/keysat-xyz/keysat-client-python.git
|
||||
python3 -c "from keysat_licensing_client import Verifier; print(Verifier)"
|
||||
```
|
||||
|
||||
## Rust SDK — `keysat-licensing-client`
|
||||
|
||||
The crate was renamed from `licensing-client` to `keysat-licensing-client`
|
||||
in `Cargo.toml` for consistency with the TS / Python names. The
|
||||
integration doc references `keysat-licensing-client`.
|
||||
|
||||
### Option A: publish to crates.io
|
||||
|
||||
```sh
|
||||
cd ~/path/to/Licensing/licensing-client-rust
|
||||
|
||||
# 1) Login (one-time): get token from https://crates.io/me.
|
||||
cargo login
|
||||
|
||||
# 2) Sanity check.
|
||||
cargo build --no-default-features --features offline
|
||||
cargo build --no-default-features --features online
|
||||
cargo test
|
||||
cargo publish --dry-run
|
||||
|
||||
# 3) Publish.
|
||||
cargo publish
|
||||
```
|
||||
|
||||
### Option B: make the GitHub repo public
|
||||
|
||||
On github.com → `keysat-xyz/keysat-client-rust` → Settings → Public.
|
||||
|
||||
Verify:
|
||||
|
||||
```sh
|
||||
cd /tmp && cargo new keysat-rust-test && cd keysat-rust-test
|
||||
cat >> Cargo.toml <<'EOF'
|
||||
keysat-licensing-client = { git = "https://github.com/keysat-xyz/keysat-client-rust.git" }
|
||||
EOF
|
||||
cargo build
|
||||
```
|
||||
|
||||
## Recommended order
|
||||
|
||||
If you're just doing the LLM-wiring test on youtube-summarizer:
|
||||
|
||||
1. **Make the three SDK repos public** (Option B for all three). Five
|
||||
clicks on github.com. Free.
|
||||
2. Re-run the LLM test against youtube-summarizer. The integration doc
|
||||
now tells the LLM both prerequisites (public repo + prepare script);
|
||||
the install commands should now succeed in a clean Docker.
|
||||
3. Defer the registry publish (Option A) to the public-launch checklist.
|
||||
|
||||
If you're getting close to public launch:
|
||||
|
||||
1. Publish all three to their registries (Option A for all three).
|
||||
2. Keep the GitHub repos visible-or-not as you prefer; consumers don't
|
||||
need them once the registry artifact exists.
|
||||
|
||||
## What broke and why
|
||||
|
||||
The downstream integrator ran into two real bugs in their own clean
|
||||
build environment:
|
||||
|
||||
1. **TS SDK installed from GitHub produced an empty package.** The
|
||||
`dist/` directory is gitignored (correct), but there was no
|
||||
`prepare` script to rebuild on git-install (incorrect). `main`
|
||||
pointed at `./dist/index.cjs`, which didn't exist. Fixed: added
|
||||
`"prepare": "npm run build"` to `package.json`.
|
||||
2. **All three SDK repos were private.** The integration doc said to
|
||||
install from GitHub as the fallback. Private repos require auth
|
||||
credentials, which Docker / CI / fresh dev machines don't have.
|
||||
Fix: flip the repos public.
|
||||
|
||||
Both bugs only manifest when the consumer runs in a clean environment —
|
||||
they're invisible during local dev because (a) `dist/` is built in your
|
||||
own working tree, (b) you have GitHub auth set up. The downstream LLM
|
||||
caught both correctly.
|
||||
@@ -0,0 +1,95 @@
|
||||
# Reset test data on a master Keysat (StartOS 0.4.0.x)
|
||||
|
||||
Pre-launch utility. Wipes every business row (products, policies, codes,
|
||||
licenses, invoices, redemptions, machines) so you can iterate cleanly.
|
||||
**Don't run this on a Keysat with real customers** — it will delete their
|
||||
licenses too. Only use on test installs before you've sold anything.
|
||||
|
||||
## What's preserved
|
||||
|
||||
- Issuer keypair (server_keys table)
|
||||
- Webhook subscribers (webhook_endpoints)
|
||||
- BTCPay connection config and tokens (settings — all `btcpay_*` keys)
|
||||
- Web UI password hash (settings)
|
||||
- Operator name (settings)
|
||||
- Audit log (audit_log)
|
||||
|
||||
The audit log is preserved on purpose — you'll see the wipe transaction
|
||||
recorded in the daemon's logs (not in the audit_log itself, since the
|
||||
wipe doesn't go through the audit path).
|
||||
|
||||
## How to run
|
||||
|
||||
Run these from any machine that has `start-cli` configured (typically
|
||||
the same workstation you used to install the .s9pk):
|
||||
|
||||
```sh
|
||||
# 1) Confirm Keysat is installed and find its package id (should be `keysat`).
|
||||
start-cli package list
|
||||
|
||||
# 2) Stop the daemon so we don't fight in-flight writes.
|
||||
start-cli package stop keysat
|
||||
|
||||
# 3) Drop into a shell inside the LXC subcontainer.
|
||||
start-cli package attach keysat
|
||||
```
|
||||
|
||||
You're now inside the Keysat container. `sqlite3` is bundled in the
|
||||
runtime image as of v0.1.0:33, so you can run the wipe directly:
|
||||
|
||||
```sh
|
||||
# Inside the container:
|
||||
sqlite3 /data/keysat.db <<'SQL'
|
||||
BEGIN;
|
||||
DELETE FROM machines;
|
||||
DELETE FROM discount_redemptions;
|
||||
DELETE FROM licenses;
|
||||
DELETE FROM invoices;
|
||||
DELETE FROM discount_codes;
|
||||
DELETE FROM policies;
|
||||
DELETE FROM products;
|
||||
COMMIT;
|
||||
SQL
|
||||
|
||||
# Quick sanity check — every row count should be 0.
|
||||
sqlite3 /data/keysat.db "SELECT
|
||||
(SELECT COUNT(*) FROM products) || ' products, ' ||
|
||||
(SELECT COUNT(*) FROM policies) || ' policies, ' ||
|
||||
(SELECT COUNT(*) FROM licenses) || ' licenses, ' ||
|
||||
(SELECT COUNT(*) FROM invoices) || ' invoices, ' ||
|
||||
(SELECT COUNT(*) FROM discount_codes) || ' codes';"
|
||||
|
||||
# Leave the container shell.
|
||||
exit
|
||||
```
|
||||
|
||||
Back on your host:
|
||||
|
||||
```sh
|
||||
# 4) Restart the daemon.
|
||||
start-cli package start keysat
|
||||
```
|
||||
|
||||
After the restart: visit `/admin/`, sign in, and you'll see an empty
|
||||
products list. Recreate everything fresh.
|
||||
|
||||
## On a pre-:33 daemon
|
||||
|
||||
The runtime image before v0.1.0:33 didn't ship with `sqlite3`. If you're
|
||||
on an older build and need to wipe before upgrading: install sqlite3
|
||||
inside the container before the wipe step:
|
||||
|
||||
```sh
|
||||
# Only needed on pre-:33 daemons.
|
||||
apt-get update && apt-get install -y sqlite3
|
||||
```
|
||||
|
||||
Then run the same SQL block.
|
||||
|
||||
## Even simpler: the in-app Delete buttons
|
||||
|
||||
As of v0.1.0:33, Products and Policies have Delete buttons in the admin
|
||||
UI (with refuse-if-referenced safety). For products/policies that have
|
||||
**no licenses tied to them**, click the buttons one by one — no SQL
|
||||
needed. Once a policy has issued any licenses (active OR revoked), the
|
||||
UI delete is refused; that's when you drop to the SQL above.
|
||||
@@ -0,0 +1,501 @@
|
||||
# Zaprite integration spec
|
||||
|
||||
Multi-provider payments for Keysat — adding Zaprite alongside BTCPay so
|
||||
operators can optionally accept card and broader-rail payments. This spec
|
||||
captures architecture, the Zaprite API shape (insofar as it can be
|
||||
verified from public sources), trade-offs, and a phased build plan.
|
||||
|
||||
## Why this matters
|
||||
|
||||
Keysat today only sells to people who have or will set up a Bitcoin
|
||||
wallet. That's a deliberate sovereignty choice but it caps the
|
||||
addressable market. Zaprite is a payment platform that sits one layer
|
||||
above payment processors and accepts:
|
||||
|
||||
- Bitcoin on-chain
|
||||
- Lightning (via the operator's own LN node, Strike, Voltage, etc.)
|
||||
- Card payments (Visa/Mastercard) via Stripe or Square
|
||||
- Liquid Bitcoin
|
||||
|
||||
For a Keysat operator, plugging Zaprite in means anyone with a credit
|
||||
card can buy their software. The operator still chooses what they get
|
||||
paid in (BTC settled to their wallet, USD settled to their bank account,
|
||||
etc.).
|
||||
|
||||
Sovereignty cost: Zaprite is a SaaS. Card payments require KYC with a
|
||||
processor. Customer PII flows through Zaprite. We mark this clearly in
|
||||
the operator-facing UI; sovereignty stays the default, fiat is opt-in.
|
||||
|
||||
## Architecture: PaymentProvider abstraction
|
||||
|
||||
Today the daemon hard-codes BTCPay assumptions throughout invoice
|
||||
creation, webhook handling, and reconciliation. The right shape is a
|
||||
trait that BTCPay and Zaprite both implement:
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait PaymentProvider: Send + Sync {
|
||||
/// What kind of provider this is — for logs, audit, and admin UI.
|
||||
fn kind(&self) -> ProviderKind;
|
||||
|
||||
/// Create a hosted-checkout session. Returns the public checkout URL
|
||||
/// that the buyer is redirected to, plus a provider-side invoice id
|
||||
/// the daemon stores in the `invoices` table.
|
||||
async fn create_invoice(
|
||||
&self,
|
||||
params: CreateInvoiceParams<'_>,
|
||||
) -> Result<CreatedInvoiceHandle>;
|
||||
|
||||
/// Fetch invoice state on demand (for reconciliation on startup).
|
||||
async fn get_invoice_status(&self, invoice_id: &str) -> Result<InvoiceStatus>;
|
||||
|
||||
/// Validate a webhook delivery. Returns the parsed event or an
|
||||
/// auth/parse error.
|
||||
fn validate_webhook(
|
||||
&self,
|
||||
headers: &HeaderMap,
|
||||
body: &[u8],
|
||||
) -> Result<WebhookEvent>;
|
||||
|
||||
/// Pay a Lightning invoice — used by the tip-recipient flow when the
|
||||
/// provider has an outgoing-LN capability. Optional; some providers
|
||||
/// won't support it (return ProviderCapabilityNotSupported).
|
||||
async fn pay_lightning_invoice(&self, bolt11: &str) -> Result<PaymentReceipt>;
|
||||
}
|
||||
|
||||
pub enum ProviderKind { Btcpay, Zaprite }
|
||||
|
||||
pub struct CreateInvoiceParams<'a> {
|
||||
pub amount: Money, // {value, currency} — sats or USD or whatever
|
||||
pub redirect_url: &'a str,
|
||||
pub metadata: &'a serde_json::Value,
|
||||
pub external_order_id: &'a str,// Keysat's invoice.id — passed back in webhooks
|
||||
pub idempotency_key: &'a str,
|
||||
pub buyer_email: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub enum WebhookEvent {
|
||||
InvoiceSettled { external_order_id: String, paid_amount: Money, .. },
|
||||
InvoiceExpired { external_order_id: String },
|
||||
InvoiceRefunded { external_order_id: String, refunded_amount: Money },
|
||||
Other { kind: String, body: serde_json::Value },
|
||||
}
|
||||
```
|
||||
|
||||
The rest of Keysat (license issuance, audit logging, admin UI) becomes
|
||||
provider-agnostic. The chosen provider is held in `AppState`:
|
||||
|
||||
```rust
|
||||
pub struct AppState {
|
||||
pub db: SqlitePool,
|
||||
pub keypair: Arc<ServerKeypair>,
|
||||
pub config: Arc<Config>,
|
||||
pub self_tier: Arc<RwLock<license_self::Tier>>,
|
||||
pub payment: Arc<RwLock<Option<Box<dyn PaymentProvider>>>>, // was: btcpay
|
||||
}
|
||||
```
|
||||
|
||||
Per-operator, not per-product. v1: an operator picks ONE provider during
|
||||
setup. All products go through it. Switching is an explicit Disconnect →
|
||||
Connect operation. Per-product provider selection is deferred.
|
||||
|
||||
## What I learned about Zaprite's API (and what's still unknown)
|
||||
|
||||
Zaprite egress is blocked from this Cowork sandbox so I couldn't read
|
||||
api.zaprite.com directly. The information below is reconstructed from
|
||||
their open-source WooCommerce plugin
|
||||
([github.com/ZapriteApp/zaprite-for-woocommerce](https://github.com/ZapriteApp/zaprite-for-woocommerce))
|
||||
and their official integration patterns. **Confirm everything before
|
||||
writing production code; I'm flagging which pieces need verification.**
|
||||
|
||||
### What I'm confident about
|
||||
|
||||
The plugin's `zaprite_api.php` shows the create-order shape clearly:
|
||||
|
||||
```php
|
||||
$data = array(
|
||||
'apiKey' => $this->api_key,
|
||||
'amount' => $amount,
|
||||
'currency' => $currency, // 'USD', 'BTC', 'SAT', 'EUR'
|
||||
'orderUpdateCallback' => $callback_url, // webhook URL
|
||||
'redirectUrl' => $complete_url,
|
||||
'externalOrderId' => $order_id,
|
||||
'externalUniqId' => $idempotency_key,
|
||||
);
|
||||
```
|
||||
|
||||
Notable: **the API key is passed in the JSON body, not in a header.**
|
||||
This is unusual and worth confirming — the public `/v1/order` endpoint
|
||||
(used by the api-demo repo, separate from the WooCommerce-specific
|
||||
endpoint) likely uses a header instead. Zaprite has been beta-API for
|
||||
a while; the conventions may have settled differently for `/v1/*`.
|
||||
|
||||
Endpoints, confirmed-by-source (WooCommerce plugin):
|
||||
- `POST {base}/api/public/woo/create-order` — plugin-specific
|
||||
- `POST {base}/api/public/woo/check-order` — plugin-specific
|
||||
|
||||
Endpoints, confirmed-by-mention (api-demo repo):
|
||||
- `POST {base}/v1/order` — general public API
|
||||
- `GET {base}/v1/order/:id` — general public API
|
||||
|
||||
Webhook delivery: Zaprite POSTs to the URL the operator passed in
|
||||
`orderUpdateCallback`. The body includes order state. The plugin code
|
||||
I saw does NOT verify a signature — it relies on the secret-ness of
|
||||
`externalUniqId` plus matching it against the locally-tracked order.
|
||||
That's not great security; the public `/v1/*` API may have a proper
|
||||
HMAC scheme.
|
||||
|
||||
Currencies: at least `USD`, `BTC`, `SAT`, `EUR`. The plugin lets the
|
||||
merchant configure store currency in WooCommerce and passes it
|
||||
through.
|
||||
|
||||
Settlement model: from blog content I saw, the operator chooses how
|
||||
each payment rail settles. BTC payments settle to a wallet they
|
||||
configure (Strike, Unchained, BTCPay, lightning node, etc.). Card
|
||||
payments settle to their connected Stripe or Square account in fiat.
|
||||
Zaprite doesn't custody funds.
|
||||
|
||||
Recurring billing: confirmed in marketing copy ("recurring invoices").
|
||||
API specifics not visible; almost certainly distinct endpoints from
|
||||
one-shot orders.
|
||||
|
||||
Pricing: $300/year subscription includes $300 of transaction fees
|
||||
(monthly subscriptions $25/mo include $25/mo of transaction fees). Above
|
||||
that, Stripe's standard 2.9% + 30¢ on cards plus Zaprite's own fee
|
||||
(percentage unclear). BTC/Lightning payments through the operator's own
|
||||
node are free of Zaprite-side fees on transactions; the subscription
|
||||
pays for the platform itself.
|
||||
|
||||
### Open questions — resolved (May 2026)
|
||||
|
||||
Resolved by reading Zaprite's actual OpenAPI spec at
|
||||
`https://api.zaprite.com/openapi.json` and the LLM-friendly summary
|
||||
at `https://api.zaprite.com/llms.txt`. Six of the original seven
|
||||
questions are now answered; one remains.
|
||||
|
||||
1. **Auth on `/v1/orders`** — Bearer token. ✅
|
||||
`Authorization: Bearer <api_key>`. One key per Zaprite
|
||||
organization. Keys created/rotated/revoked at
|
||||
`app.zaprite.com/org/default/settings/api`.
|
||||
|
||||
2. **Webhook signature scheme** — ❌ STILL UNKNOWN.
|
||||
Zaprite's public docs confirm webhooks exist and that endpoints
|
||||
should return 200 (else Zaprite retries), but the signature
|
||||
scheme isn't published. Three plausible options based on the
|
||||
operator-feedback pattern: (a) HMAC-SHA256 of the raw body in
|
||||
an `X-Zaprite-Signature` header — most common shape, what
|
||||
Stripe/BTCPay/GitHub use; (b) a JWT signed with a per-webhook
|
||||
secret; (c) no signature, in which case operators are expected
|
||||
to verify via the `externalUniqId` + a private-URL convention.
|
||||
Resolution path: when the operator creates a webhook in their
|
||||
Zaprite dashboard, the form likely shows a signing secret; the
|
||||
tooltip / docs link from THAT page should describe the exact
|
||||
scheme. We implement the impl as a small trait-internal
|
||||
function that's swappable once the truth is known. Initial
|
||||
implementation: HMAC-SHA256 in `X-Zaprite-Signature`,
|
||||
constant-time compare, fall through to a "log but accept"
|
||||
mode in dev for the first integration test.
|
||||
|
||||
3. **Currency code for sats** — `BTC`. ✅
|
||||
Zaprite's currency enum has `BTC` (also `LBTC` for Liquid).
|
||||
Amounts are in the smallest indivisible unit per currency, so
|
||||
`currency: "BTC"` + `amount: 50000` means 50,000 sats. There's
|
||||
no separate `SAT` code; `BTC` covers both display
|
||||
denominations cleanly.
|
||||
|
||||
4. **Recurring billing** — PARTIAL. ✅ (with caveat)
|
||||
Zaprite has NO native subscription endpoints (no auto-renewing
|
||||
schedules). What it offers:
|
||||
- `allowSavePaymentProfile: true` on order creation — buyer
|
||||
can save their card during checkout
|
||||
- `Contact.paymentProfiles[]` — saved profiles per contact
|
||||
- `POST /v1/orders/charge` with `paymentProfileId` — programmatic
|
||||
charge against a saved profile
|
||||
So Keysat's recurring-subscription scheduler (per
|
||||
`RECURRING_SUBSCRIPTIONS_DESIGN.md`) drives the cycle on its
|
||||
side: at each renewal, the daemon's renewal worker creates a
|
||||
new order against the buyer's saved Zaprite payment profile.
|
||||
This is actually CLEANER than relying on Zaprite-managed
|
||||
subscriptions because Keysat keeps the source of truth on
|
||||
when to bill. Trade-off: the buyer's save-card flow happens
|
||||
on the FIRST purchase; subsequent cycles are charge-without-
|
||||
redirect.
|
||||
|
||||
5. **Sandbox / test mode** — ✅ Two options:
|
||||
- **Sandbox organization** — operators can spin up a separate
|
||||
org marked as sandbox. Same API endpoints; org context
|
||||
determines real-money vs test. Recommended for staging.
|
||||
- **Test Payment plugin** — within a normal org, activate
|
||||
"Test Payment" at
|
||||
`app.zaprite.com/org/default/connections/testPayment` to
|
||||
trigger PENDING/CONFIRMED states without real funds. Useful
|
||||
for one-off integration tests.
|
||||
|
||||
6. **Refund endpoint** — ❌ Not in the public API.
|
||||
Operators handle refunds via the Zaprite dashboard. A refund
|
||||
produces some webhook event (presumably `order.refunded` or
|
||||
similar — see resolution path on Q2). For Keysat's purposes
|
||||
this is fine: we listen for the refund event and revoke the
|
||||
license; we don't need to initiate refunds programmatically
|
||||
from Keysat.
|
||||
|
||||
7. **Idempotency** — `externalUniqId` is reconciliation, not
|
||||
deduplication. ✅
|
||||
Sending the same `externalUniqId` twice DOES create two
|
||||
orders. Keysat's purchase flow already mints a UUID on
|
||||
invoice-row creation BEFORE calling create_invoice, and we
|
||||
guard against double-call client-side via the existing
|
||||
reservation pattern. No change needed.
|
||||
|
||||
### Endpoints we'll use
|
||||
|
||||
```
|
||||
POST /v1/orders — create order (one-shot or recurring cycle)
|
||||
GET /v1/orders/{id} — get order status (id can be Zaprite id OR externalUniqId)
|
||||
GET /v1/orders — list (paginated, 100/page)
|
||||
POST /v1/orders/charge — charge a saved paymentProfile (recurring cycles)
|
||||
POST /v1/webhooks — operator manages webhook endpoints
|
||||
```
|
||||
|
||||
Errors come back as `{ code, message, issues: [{ message }] }`
|
||||
with HTTP status codes `400`/`401`/`403`/`404`/`500`. Our
|
||||
ZapriteProvider impl wraps these into `AppError::Upstream` for
|
||||
consistency with the BtcpayProvider error surface.
|
||||
|
||||
### What we still need from Grant
|
||||
|
||||
Just one thing, when convenient:
|
||||
|
||||
1. **Create an API key** at the URL in your screenshot. Label it
|
||||
`keysat-test`. **Keep the secret on your machine** — paste it
|
||||
into Keysat via a future "Connect Zaprite" admin action when
|
||||
the impl is ready; don't share it in chat.
|
||||
2. **Create a sandbox webhook** (or any webhook) and screenshot
|
||||
the form. Specifically: when you click "Add Webhook" in the
|
||||
API tab, the form likely shows a "Signing Secret" field and
|
||||
either a tooltip or a docs link explaining how Zaprite signs
|
||||
payloads. That's the last bit we need.
|
||||
|
||||
The implementation can proceed today on points 1, 3-7 (which is
|
||||
most of it). Point 2 (webhook signing) gets stubbed with a
|
||||
best-guess HMAC-SHA256 + `X-Zaprite-Signature` impl that we
|
||||
correct the moment your screenshot reveals the actual scheme.
|
||||
|
||||
## Data model changes
|
||||
|
||||
### Multi-currency on products
|
||||
|
||||
```sql
|
||||
ALTER TABLE products ADD COLUMN price_currency TEXT NOT NULL DEFAULT 'SAT';
|
||||
ALTER TABLE products ADD COLUMN price_value INTEGER NOT NULL DEFAULT 0;
|
||||
-- old price_sats becomes a derived/legacy view
|
||||
```
|
||||
|
||||
Currencies live as ISO-style strings: `SAT`, `USD`, `EUR`, `BTC`. UI
|
||||
displays "$30 USD" or "50,000 sats" with a toggle. We do NOT do
|
||||
exchange-rate conversion server-side — operators denominate in one
|
||||
currency, buyers pay in whatever the chosen provider supports for that
|
||||
currency. Future: optional dual-display ("$30 / ~52,300 sats at current
|
||||
rate") but defer.
|
||||
|
||||
### Provider tag on invoices
|
||||
|
||||
```sql
|
||||
ALTER TABLE invoices ADD COLUMN provider TEXT NOT NULL DEFAULT 'btcpay';
|
||||
ALTER TABLE invoices ADD COLUMN provider_invoice_id TEXT; -- already exists as btcpay_invoice_id; rename
|
||||
```
|
||||
|
||||
Each invoice records which provider handled it. Critical for refund
|
||||
flows, chargeback handling, and historical reporting once both providers
|
||||
are in use.
|
||||
|
||||
### Refund / chargeback support
|
||||
|
||||
Card payments mean chargebacks. We need a `license_revoked_due_to_refund`
|
||||
flow:
|
||||
|
||||
```sql
|
||||
ALTER TABLE licenses ADD COLUMN revoked_reason TEXT;
|
||||
ALTER TABLE licenses ADD COLUMN revoked_at TEXT;
|
||||
```
|
||||
|
||||
When Zaprite delivers an `InvoiceRefunded` webhook, the daemon revokes
|
||||
the corresponding license, records the reason, fires a webhook
|
||||
subscriber notification (`license.revoked` event with reason
|
||||
`payment_refunded`), and the operator's records update.
|
||||
|
||||
## Phased build plan
|
||||
|
||||
### Phase 1 — PaymentProvider abstraction (v0.2)
|
||||
|
||||
Refactor the existing BTCPay code into the first impl of the trait. No
|
||||
user-visible change. Validates the abstraction.
|
||||
|
||||
| Task | Effort |
|
||||
|---|---|
|
||||
| Define `PaymentProvider` trait, `Money`, `WebhookEvent`, `CreateInvoiceParams` types | S |
|
||||
| Move existing `BtcpayClient` behind a `BtcpayProvider: PaymentProvider` impl | M |
|
||||
| Replace `state.btcpay` with `state.payment: Arc<RwLock<Option<Box<dyn PaymentProvider>>>>` | S |
|
||||
| Update webhook handler to dispatch on `provider` field of the matching invoice row | S |
|
||||
| Update reconcile loop to call provider-agnostic `get_invoice_status` | S |
|
||||
| Audit: every existing reference to "btcpay" in user-facing copy stays unchanged in v0.2 (provider chooser ships in v0.3) | XS |
|
||||
|
||||
**Net: ~2 days. Lands as v0.2.0:0.**
|
||||
|
||||
### Phase 2 — Multi-currency data model (v0.2 or v0.3)
|
||||
|
||||
| Task | Effort |
|
||||
|---|---|
|
||||
| Migration 0007 adds `price_currency`, `price_value` to products; `provider`, `provider_invoice_id` to invoices; `revoked_reason`, `revoked_at` to licenses | S |
|
||||
| Update Product / Invoice / License models | S |
|
||||
| Admin UI: currency selector on create-product form; rendering shows currency-correct units everywhere | M |
|
||||
| Marketing-page integration: purchase URL displays correct currency | S |
|
||||
|
||||
**Net: ~1.5 days. Can ride with Phase 1 in v0.2 if there's headroom; otherwise lands in v0.3.**
|
||||
|
||||
### Phase 3 — ZapriteProvider implementation (v0.3)
|
||||
|
||||
| Task | Effort |
|
||||
|---|---|
|
||||
| Verify the open Zaprite API questions (auth scheme, webhook signature, currency codes) — Grant + me reading the docs together | S |
|
||||
| Build `ZapriteProvider` impl: create_invoice, get_invoice_status, validate_webhook | M-L (depends on signature scheme; if HMAC, ~1 day; if no signature + state-matching, ~1.5 days) |
|
||||
| Map `WebhookEvent::InvoiceRefunded` → license revoke flow | S |
|
||||
| Test with sandbox keys + a real low-value card transaction | S |
|
||||
|
||||
**Net: 3-5 days. Lands in v0.3 alongside recurring billing.**
|
||||
|
||||
### Phase 4 — StartOS actions for Zaprite (v0.3)
|
||||
|
||||
| Task | Effort |
|
||||
|---|---|
|
||||
| `Connect Zaprite` action — operator pastes API key, daemon validates by hitting Zaprite's `/v1/order` test endpoint, persists, sets as active provider | S |
|
||||
| `Check Zaprite connection` action | XS |
|
||||
| `Disconnect Zaprite` action — clears stored API key, drops back to "no provider" state | XS |
|
||||
| `Switch provider` action — when operator has both BTCPay and Zaprite connected, lets them flip the active provider | S |
|
||||
|
||||
**Net: ~1 day. Lands in v0.3 with Phase 3.**
|
||||
|
||||
### Phase 5 — Admin UI updates (v0.3)
|
||||
|
||||
| Task | Effort |
|
||||
|---|---|
|
||||
| Sidebar BTCPay status indicator becomes "Payment provider" indicator with provider-kind badge | XS |
|
||||
| Create-product form: currency selector | S |
|
||||
| Invoices list: show provider column | XS |
|
||||
| New "Refunds" section in admin UI: lists `license.revoked` audit entries with `reason: payment_refunded` | S |
|
||||
| Provider chooser in onboarding wizard ("Connect a payment provider — BTCPay or Zaprite or skip for now") | M |
|
||||
|
||||
**Net: ~1.5 days. Lands in v0.3.**
|
||||
|
||||
### Phase 6 — Recurring billing via Zaprite (v0.3)
|
||||
|
||||
This is the headline v0.3 feature anyway; sequencing Zaprite work here
|
||||
is intentional.
|
||||
|
||||
| Task | Effort |
|
||||
|---|---|
|
||||
| `subscriptions` table migration | S |
|
||||
| Subscription lifecycle: created → trialing → active → past_due → canceled | M |
|
||||
| Provider-side recurring-invoice setup via Zaprite's recurring API (assuming it exists; verify in Phase 3) | M-L |
|
||||
| Auto-renewal webhook handling — settled webhook on a recurring invoice extends license expiry | S |
|
||||
| Customer-facing "manage subscription" link in receipt emails | S |
|
||||
| Admin UI subscription views | M |
|
||||
|
||||
**Net: 4-6 days. Lands in v0.3.**
|
||||
|
||||
### Phase 7 — Documentation (v0.3)
|
||||
|
||||
| Task | Effort |
|
||||
|---|---|
|
||||
| Operator runbook: signing up for Zaprite, getting API access, setting up payment rails (Strike / Unchained / Stripe / Square) | S |
|
||||
| KYC implications callout | XS |
|
||||
| Refund + chargeback flow doc | XS |
|
||||
| Fee breakdown comparison table (BTCPay-Lightning vs Zaprite-card vs Zaprite-LN-via-Strike etc.) | S |
|
||||
| Update integration guide to mention multi-currency | XS |
|
||||
| Update marketing page to mention "or accept card payments via Zaprite" once Phase 3 ships | S |
|
||||
|
||||
**Net: ~1.5 days.**
|
||||
|
||||
### Phase 8 — End-to-end testing (v0.3)
|
||||
|
||||
| Task | Effort |
|
||||
|---|---|
|
||||
| Real sandbox or low-value tests for: Zaprite create-order → buyer pays card → webhook arrives → license issued | S |
|
||||
| Test the recurring-renewal path with at least two cycles | S |
|
||||
| Test refund: refund in Zaprite → webhook arrives → license revoked | S |
|
||||
| Test provider switching: BTCPay active → switch to Zaprite → existing pending BTCPay invoices still reconcile correctly | S |
|
||||
| Test currency mixing: products in USD, products in SAT, both work in their respective providers | S |
|
||||
|
||||
**Net: 1-2 days.**
|
||||
|
||||
## Total effort
|
||||
|
||||
7-13 days of focused build work, plus testing. Realistic target: 10
|
||||
days. That's a v0.2.0 (Phase 1, possibly Phase 2) → v0.3.0 (Phases 3-8)
|
||||
arc, not a single release.
|
||||
|
||||
## Trade-offs and gotchas to be loud about in operator docs
|
||||
|
||||
- **Stripe/Zaprite fees stack on card payments.** A $30 card payment:
|
||||
$0.87 to Stripe + ~$0.20-0.50 to Zaprite + 30¢ processor fee ≈ $1.40,
|
||||
about 4.6%. BTCPay-Lightning is near-zero. Operators should know this
|
||||
when pricing.
|
||||
- **Card payments mean KYC with Stripe.** The operator becomes a
|
||||
merchant of record. Identity verified, bank account on file, business
|
||||
address required. This is genuinely incompatible with the
|
||||
privacy-preserving operator model some Keysat users want; we should be
|
||||
loud about it in the UI and in docs.
|
||||
- **Customer PII flows through Zaprite and Stripe.** With BTCPay-only,
|
||||
buyer email + npub + invoice id all live on the operator's Start9.
|
||||
Zaprite path adds buyer name, card metadata, billing address, IP, and
|
||||
potentially more in Zaprite's and Stripe's databases. Privacy policy
|
||||
needs to reflect this.
|
||||
- **Refund flow is provider-specific.** BTC: operator manually sends
|
||||
sats back. Cards: Stripe-initiated refund via Zaprite's UI (or API),
|
||||
webhook arrives, license auto-revokes. Two different UX paths, both
|
||||
documented.
|
||||
- **Subscription cancellation.** With recurring billing, buyers can
|
||||
cancel via their card issuer (chargeback) or via a self-service portal
|
||||
we'd build. Both paths need to fire `subscription.canceled` and update
|
||||
license expiry semantics.
|
||||
|
||||
## What this enables longer term
|
||||
|
||||
The PaymentProvider abstraction is defensive even if Zaprite never ships
|
||||
— it's the right shape for adding any future provider:
|
||||
|
||||
- **Strike API directly.** Skip Zaprite entirely; integrate Strike's API
|
||||
for sat-denominated payments without going through their checkout.
|
||||
- **Coinbase Commerce.** Easy add-on for operators who already have a
|
||||
Coinbase Commerce account.
|
||||
- **OpenNode.** Another Bitcoin-native processor.
|
||||
- **PayPal / direct Square.** For operators willing to skip Zaprite.
|
||||
|
||||
Each is a new `PaymentProvider` impl. ~3-5 days each once the
|
||||
abstraction is in.
|
||||
|
||||
## Sources
|
||||
|
||||
- [Zaprite developers page](https://zaprite.com/developers) (documented
|
||||
but inaccessible from this sandbox; Grant should read directly)
|
||||
- [Zaprite API & webhooks blog post](https://blog.zaprite.com/opening-zaprites-api-webhooks/)
|
||||
- [Zaprite WooCommerce plugin source — `zaprite_api.php`](https://github.com/ZapriteApp/zaprite-for-woocommerce/blob/main/zaprite-payment-gateway/includes/zaprite_api.php)
|
||||
- [Zaprite api-demo repo](https://github.com/zapriteapp/zaprite-api-demo)
|
||||
- [Zaprite pricing page](https://zaprite.com/pricing)
|
||||
- [Zaprite API reference](https://api.zaprite.com/) (egress-blocked from
|
||||
this sandbox — Grant has direct access)
|
||||
|
||||
## My next step recommendations
|
||||
|
||||
1. **Grant signs up for a free Zaprite account, requests API access at
|
||||
Settings → API, and pastes back what api.zaprite.com/v1 actually
|
||||
shows for create-order and webhooks.** That answers the seven open
|
||||
questions above and lets me write production-grade code.
|
||||
2. **Phase 1 (PaymentProvider abstraction) can start now without any
|
||||
Zaprite specifics** — it's a refactor of existing BTCPay code. I can
|
||||
do this autonomously and have it ready for v0.2.0:0 alongside the
|
||||
web UI auth hardening already queued.
|
||||
3. **Phases 2-8 wait on the Zaprite API confirmation.** No point coding
|
||||
against a guessed-at API.
|
||||
|
||||
Want me to start Phase 1 now while you go grab the Zaprite docs?
|
||||
@@ -0,0 +1,172 @@
|
||||
# Keysat Design System
|
||||
|
||||
> *Bitcoin-paid software licensing, self-hosted on Start9.*
|
||||
|
||||
Keysat is a **self-hosted licensing server** that indie software creators run on their own [Start9](https://start9.com) server. Buyers pay in Bitcoin via the creator's own [BTCPay Server](https://btcpayserver.org); Keysat issues an Ed25519-signed license key; the creator's software verifies that key offline against an embedded public key. **No SaaS, no middleman, no platform risk** — the creator owns the signing key, the customer list, and the payment rails.
|
||||
|
||||
The brand sits at the intersection of **classical trust signaling** (a notarized certificate, a vault key, a printed share certificate) and **modern indie-software practicality** — friendly, accessible, sovereign. The visual identity is anchored by the logo: a **deep navy key** crossing a **Bitcoin "B" bow**, set on **cream paper** with a **gold inner border**. That paper-and-ink character — restrained, classical, slightly archival — runs through the whole system.
|
||||
|
||||
---
|
||||
|
||||
## Source Materials
|
||||
|
||||
- `assets/keysat-logo-thumbnail.png` — the original 1024×1024 logo on cream paper texture, with the wordmark "KEYSAT" and tagline "Software Licensing for Bitcoin Creators."
|
||||
- `assets/keysat-draft-site.html` — the draft single-page marketing site shipped with the project. Useful for **product context, copy, and feature scope** (see the value-prop grid, the "How it works" 5-step flow, the install instructions). The visual style is intentionally being replaced by this design system — do not pattern-match on its dark-mode + amber palette.
|
||||
|
||||
---
|
||||
|
||||
## Products
|
||||
|
||||
This system covers three surfaces:
|
||||
|
||||
1. **Marketing website** (`ui_kits/marketing/`) — public-facing single-page site. Hero, value props, how-it-works, integration code samples, install instructions, sovereign-by-default panel.
|
||||
2. **Creator admin dashboard** (`ui_kits/dashboard/`) — authenticated. Where creators manage products, policies, license keys, discount codes, customers, audit log. Runs on the creator's own Start9.
|
||||
3. **Docs site** (`ui_kits/docs/`) — developer-facing reference for the licensing wire format, SDKs (Rust / TypeScript / Python), and integration steps.
|
||||
|
||||
Note: there is **no "payouts" surface** — BTCPay Server handles all money. Keysat only deals with licenses, products, and customers.
|
||||
|
||||
---
|
||||
|
||||
## Index of Files
|
||||
|
||||
```
|
||||
.
|
||||
├── README.md ← this file
|
||||
├── SKILL.md ← skill manifest (Agent Skills compatible)
|
||||
├── colors_and_type.css ← all CSS variables + base type styles
|
||||
├── assets/ ← logos, marks, the source draft site
|
||||
├── preview/ ← design-system review cards (auto-rendered)
|
||||
└── ui_kits/
|
||||
├── marketing/ ← landing page (single-page redesign)
|
||||
├── dashboard/ ← admin: products, licenses, customers, audit
|
||||
└── docs/ ← API reference, SDK guides
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Content Fundamentals
|
||||
|
||||
Keysat speaks to **indie creators selling software** — solo developers, small studios, makers of paid CLI tools, plugins, fonts, audio plugins, design tools — who care about **owning their stack**. They're technical and politically opinionated about platforms. They want plain answers, not crypto-bro hype.
|
||||
|
||||
**Voice & Tone**
|
||||
|
||||
- **Direct, plainspoken.** "Get paid in Bitcoin. Keep your signing key." not "Unlock the future of decentralized monetization."
|
||||
- **Friendly but quietly serious.** This is money + cryptography software. We don't joke about losses, signing keys, or platform risk. We are warm but precise.
|
||||
- **Sovereignty-first framing.** Copy reliably points at *what you own*: the key, the customer list, the payment rails. This is the brand's center of gravity.
|
||||
- **"You" speaks to the creator** in marketing and dashboard copy. "We" speaks for Keysat sparingly.
|
||||
- **No jargon walls.** When we have to use a term ("Ed25519", "BTCPay webhook", "sideload .s9pk"), we link to a one-sentence definition or example. We never assume the reader is already a Start9 user.
|
||||
- **No hype words.** Avoid: *revolutionary, seamless, unlock, supercharge, leverage, ecosystem, journey, paradigm, game-changing.*
|
||||
- **No emoji** in product UI. The brand has a quiet, archival quality. (The draft site uses ⚡🔐📡🎫🏷️🛠️ in the value grid — these will be replaced with Lucide icons.)
|
||||
|
||||
**Casing**
|
||||
|
||||
- **Sentence case** for buttons, menu items, headings: "Create a license", "Connect BTCPay".
|
||||
- **ALL CAPS with wide tracking** for eyebrow labels above section headings ("FOR CREATORS", "HOW IT WORKS"). Sparingly.
|
||||
- Product / proper nouns capitalized: **Keysat, Start9, StartOS, BTCPay, Bitcoin, Lightning, Ed25519**.
|
||||
|
||||
**Numbers, money, identifiers**
|
||||
|
||||
- Bitcoin amounts: `0.00214 BTC` or `214,000 sats`. Default to **sats** in the dashboard for amounts under 0.01 BTC.
|
||||
- Fiat alongside crypto: `0.00214 BTC ≈ $128.40`.
|
||||
- License keys are monospace, hyphen-grouped: `KS-9F2A-7C41-XK22-6D8E`.
|
||||
- Public keys / hashes: monospace, ellipsized middle: `mz7q8…h3k2p`.
|
||||
- File extensions / commands: inline mono, no decoration: `.s9pk`, `cargo add`, `npm install`.
|
||||
|
||||
**Examples (good ↔ bad)**
|
||||
|
||||
| Good | Bad |
|
||||
|---|---|
|
||||
| Bitcoin-paid software licensing, self-hosted on Start9. | Unlock the future of crypto-native software monetization ⚡️ |
|
||||
| You own the signing key, the customer list, and the payment rails. | Revolutionary, seamless, decentralized rights management. |
|
||||
| Five lines of integration code. Verifies real signatures. | Game-changing developer experience. |
|
||||
| Connect BTCPay → Define products → Issue keys. | A frictionless creator journey. |
|
||||
| Payout received: 214,000 sats | 🎉 Cha-ching! New sale! |
|
||||
|
||||
---
|
||||
|
||||
## Visual Foundations
|
||||
|
||||
The brand is **navy ink on cream paper, with a gold accent that whispers**. Think: a certificate of authenticity, a vault deed, a hand-numbered print. Modern in interaction, classical in composition.
|
||||
|
||||
### Palette
|
||||
|
||||
- **Navy** is the primary brand color. `--navy-800` (`#1E3A5F`) is the wordmark color and dominant ink. Used for primary buttons, headings, key UI chrome.
|
||||
- **Cream** is the page background. `--cream-100` (`#F5F1E8`) is the default; `--cream-50` for elevated paper. Pure white is reserved for forms, tables, and code blocks where contrast matters.
|
||||
- **Gold** (`--gold-500`, `#BFA068`) is the **accent color, used sparingly** — eyebrow labels, dividers, the inner stroke of premium cards, the highlight on a verified badge. **Never as a primary button color.**
|
||||
- **Ink** scale (`--ink-900` → `--ink-300`) handles all body text, secondary copy, and disabled states.
|
||||
- **No bluish-purple gradients. No Bitcoin orange in the UI** (per user direction — the navy/cream identity stands alone).
|
||||
|
||||
### Typography
|
||||
|
||||
- **Archivo** (display) — geometric sans, heavy at 800–900, mirrors the wordmark. Used for h1–h4, large numerals.
|
||||
- **Inter** (body) — neutral humanist sans for paragraph text, UI labels, form fields. Stylistic sets `ss01` + `cv11`.
|
||||
- **JetBrains Mono** (mono) — license keys, code samples, API responses, transaction IDs.
|
||||
|
||||
**Substitution flag**: Archivo is loaded from Google Fonts. The thumbnail wordmark looks closest to a custom geometric sans; Archivo is my best Google Fonts match. If Keysat has a licensed display face, swap `--font-display` and remove the Google import.
|
||||
|
||||
### Spacing & Layout
|
||||
|
||||
- 4px base grid. Tokens `--sp-1` (4) through `--sp-12` (128).
|
||||
- Marketing pages breathe: sections often `--sp-11` (96px) apart. Dashboard density is moderate: table rows ~52px, card padding `--sp-6` (24px).
|
||||
- Max content width on marketing: 1200px. Reading width for prose / docs: 680px.
|
||||
|
||||
### Backgrounds
|
||||
|
||||
- Default page background: **cream with a subtle grain** (`paper-texture` utility — two overlaid radial-dot grids at 2.5% opacity). Never pure flat color.
|
||||
- Section bands alternate cream → cream-200 → cream-50 → navy-950 (for dark CTAs / footers). **No blue gradients, no glassmorphism, no purple.**
|
||||
- Imagery, when present, is photographic with a warm/natural cast — printed paper, hardware (Coldcard, Start9 server), workshop scenes — not stock-photo people pointing at laptops. Black-and-white or duotone (navy/cream) for editorial feel.
|
||||
|
||||
### Borders, Radii & Cards
|
||||
|
||||
- **Radii are restrained.** Buttons: `--r-md` (8px). Cards: `--r-lg` (12px). Pills only for tags/badges. **No 24px+ rounding** — it makes the brand look like a fintech consumer app, which we are not.
|
||||
- **Cards** sit on cream with a hairline border (`--border-1`, navy at 12% opacity) and a quiet shadow (`--shadow-sm`). Premium / featured cards get a 1px gold inner stroke (`--gold-500`) and `--shadow-md`.
|
||||
- Section dividers can use a thin gold line (`--gold-500`) at 1px — sparingly, as a typographic flourish.
|
||||
|
||||
### Shadows
|
||||
|
||||
A **paper-shadow** system, not a glassy one:
|
||||
|
||||
- `--shadow-xs`, `--shadow-sm` for resting cards.
|
||||
- `--shadow-md` for elevated cards, premium cards.
|
||||
- `--shadow-lg` for popovers, menus.
|
||||
- `--shadow-xl` for full modals, command palettes.
|
||||
- `--shadow-inset` adds a top-light/bottom-shade to give buttons subtle paper relief.
|
||||
|
||||
### Motion
|
||||
|
||||
**Quiet, fast, no bouncing.** `--ease-standard` for most things, `--ease-out` for entrances. Default duration `--dur-base` (200ms). Hover transitions `--dur-fast` (120ms). **No spring physics, no scale-up on hover.** Cards move at most 1px on hover. Buttons darken; they don't grow.
|
||||
|
||||
### Hover & Press States
|
||||
|
||||
- **Buttons (primary navy):** hover → `--navy-900`; press → `--navy-950` + 1px translate-down. No scale.
|
||||
- **Buttons (secondary):** hover → background `--cream-200`; press → `--cream-300`.
|
||||
- **Buttons (ghost):** hover → background `rgba(14,31,51,0.05)`.
|
||||
- **Links:** hover → darker shade + thicker underline (`text-decoration-thickness: 2px`).
|
||||
- **Cards (interactive):** hover → border darkens to `--border-2`, shadow steps from `sm` → `md`.
|
||||
- **List rows:** hover → `--cream-50` background.
|
||||
|
||||
### Transparency & Blur
|
||||
|
||||
Used **rarely**. Acceptable: sticky marketing header (`backdrop-filter: blur(12px)` over `rgba(245,241,232,0.85)`); modal scrim (`rgba(14,31,51,0.55)`). Otherwise prefer solid surfaces. The brand reads as **printed**, not liquid.
|
||||
|
||||
### Focus States
|
||||
|
||||
Every focusable element gets a 3px navy halo (`--ring-focus`) at 25% opacity, offset 2px. Never remove focus rings.
|
||||
|
||||
---
|
||||
|
||||
## Iconography
|
||||
|
||||
- **Lucide** — primary icon system, loaded from CDN. Stroke 1.75px. Modern, restrained, line-based — matches the engineering-but-classical brand. 16px (inline), 20px (UI default), 24px (section headers).
|
||||
- **Custom marks for Bitcoin** — the Bitcoin "B" glyph is part of the logo and is also used as a standalone unit symbol. Currency in UI uses `₿` (U+20BF) inline.
|
||||
- **The Keysat logo mark** at `assets/keysat-mark.svg` — never recolored, always navy or, on dark surfaces, cream.
|
||||
- **No emoji** in product UI. The draft site's emoji icons (⚡🔐📡🎫🏷️🛠️) are replaced 1:1 by Lucide: `zap`, `key-round`, `wifi-off`, `ticket`, `tag`, `wrench`.
|
||||
- **No PNG icons** in the UI. Only the rasterized logo thumbnail is PNG; everything else is SVG.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions / Caveats
|
||||
|
||||
- The display typeface is a **Google Fonts substitution** (Archivo). If Keysat has a licensed display face, please supply it.
|
||||
- Only a 1024×1024 PNG logo was provided. **No vector logo, horizontal lockup, monochrome version, or favicon were available.** I generated a clean SVG mark + wordmark; please review against the source.
|
||||
- The draft HTML site was used for **content + product scope only** — its dark-mode + amber visual style was explicitly replaced.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: keysat-design
|
||||
description: Use this skill to generate well-branded interfaces and assets for Keysat — a self-hosted, Bitcoin-paid software-licensing server that runs on Start9. Use for production code or throwaway prototypes / mocks. Contains essential design guidelines, colors, type, fonts, assets, and UI-kit components for prototyping.
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
Read the README.md file within this skill, and explore the other available files.
|
||||
|
||||
If creating visual artifacts (slides, mocks, throwaway prototypes, etc), copy assets out and create static HTML files for the user to view. If working on production code, you can copy assets and read the rules here to become an expert in designing with this brand.
|
||||
|
||||
If the user invokes this skill without any other guidance, ask them what they want to build or design, ask some questions, and act as an expert designer who outputs HTML artifacts _or_ production code, depending on the need.
|
||||
|
||||
## Quick orientation
|
||||
|
||||
- **Visual identity**: navy ink (`#1E3A5F`) on cream paper (`#F5F1E8`), with a sparing gold accent (`#BFA068`). Classical, archival, slightly print-leaning. No bluish-purple gradients, no glassmorphism, no Bitcoin orange in the UI.
|
||||
- **Type**: Archivo (display, 800/900) + Inter (body) + JetBrains Mono (license keys + code). Loaded from Google Fonts.
|
||||
- **Tone**: friendly + plainspoken + sovereignty-first. "You own the signing key." No emoji in product UI.
|
||||
- **Iconography**: Lucide via CDN.
|
||||
- **Three surfaces**: marketing (`ui_kits/marketing/`), creator dashboard (`ui_kits/dashboard/`), docs (`ui_kits/docs/`).
|
||||
|
||||
Always read `README.md` first; reach for `colors_and_type.css` for tokens; copy `assets/keysat-logo-thumbnail.png` (or the SVG marks) into the work.
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<text x="16" y="24" text-anchor="middle" font-family="Archivo, Helvetica, sans-serif" font-weight="900" font-size="28" fill="#1E3A5F">₿</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 226 B |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
|
||||
<rect width="32" height="32" rx="6" fill="#F5F1E8"></rect>
|
||||
<ellipse cx="16" cy="9" rx="9" ry="1.6" fill="#1E3A5F"></ellipse>
|
||||
<rect x="7" y="9" width="18" height="16" fill="#FBF9F2" stroke="#1E3A5F" stroke-width="1.4"></rect>
|
||||
<ellipse cx="16" cy="25" rx="9" ry="1.6" fill="#1E3A5F"></ellipse>
|
||||
<circle cx="13" cy="17" r="2.6" fill="none" stroke="#BFA068" stroke-width="1.4"></circle>
|
||||
<rect x="15.6" y="16.4" width="6" height="1.5" fill="#BFA068"></rect>
|
||||
<rect x="20" y="17.9" width="0.9" height="1.8" fill="#BFA068"></rect>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 618 B |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 80" fill="none">
|
||||
<g transform="translate(0,0)">
|
||||
<ellipse cx="40" cy="20" rx="26" ry="4.5" fill="#1E3A5F"></ellipse>
|
||||
<rect x="14" y="20" width="52" height="52" fill="#FBF9F2" stroke="#1E3A5F" stroke-width="2.8"></rect>
|
||||
<ellipse cx="40" cy="72" rx="26" ry="4.5" fill="#1E3A5F"></ellipse>
|
||||
<line x1="23" y1="33" x2="57" y2="33" stroke="#1E3A5F" stroke-width="1.4" stroke-linecap="round"></line>
|
||||
<line x1="23" y1="40" x2="52" y2="40" stroke="#1E3A5F" stroke-width="1.4" stroke-linecap="round"></line>
|
||||
<circle cx="32" cy="55" r="5.5" fill="none" stroke="#BFA068" stroke-width="2.3"></circle>
|
||||
<rect x="38" y="53.7" width="13" height="2.7" fill="#BFA068"></rect>
|
||||
<rect x="47" y="56.4" width="1.8" height="3.6" fill="#BFA068"></rect>
|
||||
<rect x="51" y="56.4" width="1.8" height="2.7" fill="#BFA068"></rect>
|
||||
</g>
|
||||
<text x="92" y="52" font-family="Manrope, system-ui, sans-serif" font-weight="500" font-size="32" letter-spacing="9" fill="#1E3A5F">KEYSAT</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
|
||||
|
||||
<ellipse cx="50" cy="22" rx="28" ry="5" fill="#1E3A5F"></ellipse>
|
||||
<rect x="22" y="22" width="56" height="56" fill="none" stroke="#1E3A5F" stroke-width="3"></rect>
|
||||
<ellipse cx="50" cy="78" rx="28" ry="5" fill="#1E3A5F"></ellipse>
|
||||
<line x1="32" y1="36" x2="68" y2="36" stroke="#1E3A5F" stroke-width="1.5" stroke-linecap="round"></line>
|
||||
<line x1="32" y1="44" x2="62" y2="44" stroke="#1E3A5F" stroke-width="1.5" stroke-linecap="round"></line>
|
||||
<circle cx="42" cy="60" r="6" fill="none" stroke="#1E3A5F" stroke-width="2.5"></circle>
|
||||
<rect x="48" y="58.5" width="14" height="3" fill="#1E3A5F"></rect>
|
||||
<rect x="58" y="61.5" width="2" height="4" fill="#1E3A5F"></rect>
|
||||
<rect x="62" y="61.5" width="2" height="3" fill="#1E3A5F"></rect>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 828 B |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
|
||||
|
||||
<ellipse cx="50" cy="22" rx="28" ry="5" fill="#FBF9F2"></ellipse>
|
||||
<rect x="22" y="22" width="56" height="56" fill="#0E1F33" stroke="#FBF9F2" stroke-width="3"></rect>
|
||||
<ellipse cx="50" cy="78" rx="28" ry="5" fill="#FBF9F2"></ellipse>
|
||||
<line x1="32" y1="36" x2="68" y2="36" stroke="#FBF9F2" stroke-width="1.5" stroke-linecap="round"></line>
|
||||
<line x1="32" y1="44" x2="62" y2="44" stroke="#FBF9F2" stroke-width="1.5" stroke-linecap="round"></line>
|
||||
<circle cx="42" cy="60" r="6" fill="none" stroke="#BFA068" stroke-width="2.5"></circle>
|
||||
<rect x="48" y="58.5" width="14" height="3" fill="#BFA068"></rect>
|
||||
<rect x="58" y="61.5" width="2" height="4" fill="#BFA068"></rect>
|
||||
<rect x="62" y="61.5" width="2" height="3" fill="#BFA068"></rect>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 831 B |
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
|
||||
|
||||
<ellipse cx="50" cy="22" rx="28" ry="5" fill="#1E3A5F"></ellipse>
|
||||
|
||||
<rect x="22" y="22" width="56" height="56" fill="#FBF9F2" stroke="#1E3A5F" stroke-width="3"></rect>
|
||||
|
||||
<ellipse cx="50" cy="78" rx="28" ry="5" fill="#1E3A5F"></ellipse>
|
||||
|
||||
<line x1="32" y1="36" x2="68" y2="36" stroke="#1E3A5F" stroke-width="1.5" stroke-linecap="round"></line>
|
||||
<line x1="32" y1="44" x2="62" y2="44" stroke="#1E3A5F" stroke-width="1.5" stroke-linecap="round"></line>
|
||||
|
||||
<circle cx="42" cy="60" r="6" fill="none" stroke="#BFA068" stroke-width="2.5"></circle>
|
||||
<rect x="48" y="58.5" width="14" height="3" fill="#BFA068"></rect>
|
||||
<rect x="58" y="61.5" width="2" height="4" fill="#BFA068"></rect>
|
||||
<rect x="62" y="61.5" width="2" height="3" fill="#BFA068"></rect>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 843 B |
@@ -0,0 +1,280 @@
|
||||
/* ============================================================
|
||||
Keysat Design System — Colors & Type
|
||||
"Software Licensing for Bitcoin Creators"
|
||||
Navy + cream, paper texture, classical type.
|
||||
============================================================ */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||
|
||||
:root {
|
||||
/* ---------- Brand Colors ---------- */
|
||||
/* Primary navy — pulled from the wordmark */
|
||||
--navy-950: #0E1F33;
|
||||
--navy-900: #142A47;
|
||||
--navy-800: #1E3A5F; /* core brand navy */
|
||||
--navy-700: #2A4A75;
|
||||
--navy-600: #3A5C8A;
|
||||
--navy-500: #5074A1;
|
||||
--navy-400: #7892B8;
|
||||
--navy-300: #A6B7CF;
|
||||
--navy-200: #CBD5E2;
|
||||
--navy-100: #E4EAF1;
|
||||
--navy-50: #F2F5F9;
|
||||
|
||||
/* Cream / paper — the background tone of the logo card */
|
||||
--cream-50: #FBF9F2;
|
||||
--cream-100: #F5F1E8; /* core cream */
|
||||
--cream-200: #EDE7D7;
|
||||
--cream-300: #E1D8C0;
|
||||
--cream-400: #C9BC9A;
|
||||
|
||||
/* Gold / tan — the inner key border */
|
||||
--gold-700: #8A6F3D;
|
||||
--gold-600: #A88652;
|
||||
--gold-500: #BFA068; /* core gold accent */
|
||||
--gold-400: #D4B985;
|
||||
--gold-300: #E5CFA5;
|
||||
--gold-200: #F0E2C5;
|
||||
|
||||
/* Ink — dark text */
|
||||
--ink-900: #0E1F33;
|
||||
--ink-700: #2C3E54;
|
||||
--ink-500: #5A6B7F;
|
||||
--ink-400: #7E8C9D;
|
||||
--ink-300: #A4AEBB;
|
||||
|
||||
/* Semantic */
|
||||
--success: #2D7A5F;
|
||||
--success-bg: #E3F0EA;
|
||||
--warning: #B8861F;
|
||||
--warning-bg: #F7EFD7;
|
||||
--danger: #B23A3A;
|
||||
--danger-bg: #F4E0E0;
|
||||
--info: var(--navy-700);
|
||||
--info-bg: var(--navy-100);
|
||||
|
||||
/* ---------- Semantic surface tokens ---------- */
|
||||
--bg-page: var(--cream-100); /* default page bg */
|
||||
--bg-paper: var(--cream-50); /* lighter paper */
|
||||
--bg-elev: #FFFFFF; /* elevated surface (cards on cream) */
|
||||
--bg-inverse: var(--navy-900); /* dark surface */
|
||||
--bg-tint: var(--cream-200); /* tinted band/section */
|
||||
|
||||
--fg-1: var(--ink-900); /* primary text */
|
||||
--fg-2: var(--ink-700); /* secondary text */
|
||||
--fg-3: var(--ink-500); /* tertiary / meta */
|
||||
--fg-4: var(--ink-400); /* disabled / hint */
|
||||
--fg-on-navy: var(--cream-50);
|
||||
--fg-on-gold: var(--navy-900);
|
||||
|
||||
--border-1: rgba(14, 31, 51, 0.12); /* hairline on cream */
|
||||
--border-2: rgba(14, 31, 51, 0.20); /* card border */
|
||||
--border-3: rgba(14, 31, 51, 0.35); /* focus / strong */
|
||||
--border-on-navy: rgba(245, 241, 232, 0.18);
|
||||
|
||||
--accent: var(--navy-800);
|
||||
--accent-hover: var(--navy-900);
|
||||
--accent-press: var(--navy-950);
|
||||
--accent-soft: var(--navy-100);
|
||||
|
||||
--gold: var(--gold-500);
|
||||
--gold-hover: var(--gold-600);
|
||||
|
||||
/* ---------- Type families ---------- */
|
||||
--font-display: 'Manrope', 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-body: 'Inter', 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
|
||||
/* ---------- Type scale ---------- */
|
||||
--fs-display-xl: clamp(56px, 6vw, 88px);
|
||||
--fs-display: clamp(40px, 4.5vw, 64px);
|
||||
--fs-h1: 44px;
|
||||
--fs-h2: 32px;
|
||||
--fs-h3: 24px;
|
||||
--fs-h4: 20px;
|
||||
--fs-h5: 17px;
|
||||
--fs-body-lg: 18px;
|
||||
--fs-body: 15px;
|
||||
--fs-body-sm: 13.5px;
|
||||
--fs-meta: 12px;
|
||||
--fs-mono: 13px;
|
||||
|
||||
/* ---------- Line heights ---------- */
|
||||
--lh-display: 1.02;
|
||||
--lh-heading: 1.15;
|
||||
--lh-body: 1.55;
|
||||
--lh-tight: 1.25;
|
||||
|
||||
/* ---------- Letter spacing ---------- */
|
||||
--tracking-tight: -0.02em;
|
||||
--tracking-normal: 0;
|
||||
--tracking-wide: 0.04em;
|
||||
--tracking-eyebrow: 0.18em;
|
||||
|
||||
/* ---------- Spacing (4px base) ---------- */
|
||||
--sp-1: 4px;
|
||||
--sp-2: 8px;
|
||||
--sp-3: 12px;
|
||||
--sp-4: 16px;
|
||||
--sp-5: 20px;
|
||||
--sp-6: 24px;
|
||||
--sp-7: 32px;
|
||||
--sp-8: 40px;
|
||||
--sp-9: 56px;
|
||||
--sp-10: 72px;
|
||||
--sp-11: 96px;
|
||||
--sp-12: 128px;
|
||||
|
||||
/* ---------- Radii ---------- */
|
||||
--r-xs: 3px;
|
||||
--r-sm: 5px;
|
||||
--r-md: 8px;
|
||||
--r-lg: 12px;
|
||||
--r-xl: 18px;
|
||||
--r-2xl: 24px;
|
||||
--r-pill: 999px;
|
||||
|
||||
/* ---------- Shadows ---------- */
|
||||
/* Quiet, layered shadows — paper, not glassy */
|
||||
--shadow-xs: 0 1px 1px rgba(14,31,51,0.04);
|
||||
--shadow-sm: 0 1px 2px rgba(14,31,51,0.06), 0 1px 1px rgba(14,31,51,0.03);
|
||||
--shadow-md: 0 2px 4px rgba(14,31,51,0.06), 0 4px 12px rgba(14,31,51,0.06);
|
||||
--shadow-lg: 0 4px 8px rgba(14,31,51,0.07), 0 12px 32px rgba(14,31,51,0.10);
|
||||
--shadow-xl: 0 8px 16px rgba(14,31,51,0.10), 0 24px 64px rgba(14,31,51,0.14);
|
||||
--shadow-inset: inset 0 1px 0 rgba(255,255,255,0.6), inset 0 -1px 0 rgba(14,31,51,0.05);
|
||||
--ring-focus: 0 0 0 3px rgba(30,58,95,0.25);
|
||||
|
||||
/* ---------- Motion ---------- */
|
||||
--ease-standard: cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in: cubic-bezier(0.7, 0, 0.84, 0);
|
||||
--dur-fast: 120ms;
|
||||
--dur-base: 200ms;
|
||||
--dur-slow: 360ms;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Paper texture — subtle grain on cream surfaces
|
||||
============================================================ */
|
||||
.paper-texture {
|
||||
background-color: var(--bg-page);
|
||||
background-image:
|
||||
radial-gradient(rgba(14,31,51,0.025) 1px, transparent 1px),
|
||||
radial-gradient(rgba(138,111,61,0.022) 1px, transparent 1px);
|
||||
background-size: 3px 3px, 7px 7px;
|
||||
background-position: 0 0, 1px 1px;
|
||||
}
|
||||
.paper-texture-strong {
|
||||
background-color: var(--bg-page);
|
||||
background-image:
|
||||
radial-gradient(rgba(14,31,51,0.04) 1px, transparent 1.4px),
|
||||
radial-gradient(rgba(138,111,61,0.035) 1px, transparent 1.2px);
|
||||
background-size: 3px 3px, 7px 7px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Element defaults — drop these into a body class .keysat
|
||||
============================================================ */
|
||||
.keysat {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--fs-body);
|
||||
line-height: var(--lh-body);
|
||||
color: var(--fg-1);
|
||||
background: var(--bg-page);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-feature-settings: 'ss01', 'cv11';
|
||||
}
|
||||
|
||||
.keysat h1, .keysat .h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--fs-h1);
|
||||
font-weight: 500;
|
||||
line-height: var(--lh-heading);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
color: var(--fg-1);
|
||||
margin: 0;
|
||||
}
|
||||
.keysat h2, .keysat .h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--fs-h2);
|
||||
font-weight: 500;
|
||||
line-height: var(--lh-heading);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
color: var(--fg-1);
|
||||
margin: 0;
|
||||
}
|
||||
.keysat h3, .keysat .h3 {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--fs-h3);
|
||||
font-weight: 600;
|
||||
line-height: var(--lh-tight);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
margin: 0;
|
||||
}
|
||||
.keysat h4, .keysat .h4 {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--fs-h4);
|
||||
font-weight: 600;
|
||||
line-height: var(--lh-tight);
|
||||
margin: 0;
|
||||
}
|
||||
.keysat h5, .keysat .h5 {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--fs-h5);
|
||||
font-weight: 600;
|
||||
line-height: var(--lh-tight);
|
||||
margin: 0;
|
||||
}
|
||||
.keysat .display-xl {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--fs-display-xl);
|
||||
font-weight: 500;
|
||||
line-height: var(--lh-display);
|
||||
letter-spacing: -0.022em;
|
||||
}
|
||||
.keysat .display {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--fs-display);
|
||||
font-weight: 500;
|
||||
line-height: var(--lh-display);
|
||||
letter-spacing: -0.022em;
|
||||
}
|
||||
.keysat .wordmark {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.32em;
|
||||
text-transform: uppercase;
|
||||
color: var(--navy-800);
|
||||
}
|
||||
.keysat .eyebrow {
|
||||
font-family: var(--font-body);
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: var(--tracking-eyebrow);
|
||||
text-transform: uppercase;
|
||||
color: var(--gold-700);
|
||||
}
|
||||
.keysat p { margin: 0 0 1em 0; color: var(--fg-2); }
|
||||
.keysat .lead {
|
||||
font-size: var(--fs-body-lg);
|
||||
line-height: 1.5;
|
||||
color: var(--fg-2);
|
||||
}
|
||||
.keysat .meta {
|
||||
font-size: var(--fs-meta);
|
||||
color: var(--fg-3);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.keysat code, .keysat .mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-mono);
|
||||
font-feature-settings: 'ss02';
|
||||
}
|
||||
.keysat a {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.keysat a:hover { color: var(--accent-hover); }
|
||||
@@ -0,0 +1,936 @@
|
||||
|
||||
// DesignCanvas.jsx — Figma-ish design canvas wrapper
|
||||
// Warm gray grid bg + Sections + Artboards + PostIt notes.
|
||||
// Artboards are reorderable (grip-drag), deletable, labels/titles are
|
||||
// inline-editable, and any artboard can be opened in a fullscreen focus
|
||||
// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar
|
||||
// via the host bridge. No assets, no deps.
|
||||
//
|
||||
// Usage:
|
||||
// <DesignCanvas>
|
||||
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
|
||||
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
|
||||
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
|
||||
// </DCSection>
|
||||
// </DesignCanvas>
|
||||
|
||||
const DC = {
|
||||
bg: '#f0eee9',
|
||||
grid: 'rgba(0,0,0,0.06)',
|
||||
label: 'rgba(60,50,40,0.7)',
|
||||
title: 'rgba(40,30,20,0.85)',
|
||||
subtitle: 'rgba(60,50,40,0.6)',
|
||||
postitBg: '#fef4a8',
|
||||
postitText: '#5a4a2a',
|
||||
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
|
||||
};
|
||||
|
||||
// One-time CSS injection (classes are dc-prefixed so they don't collide with
|
||||
// the hosted design's own styles).
|
||||
if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
|
||||
const s = document.createElement('style');
|
||||
s.id = 'dc-styles';
|
||||
s.textContent = [
|
||||
'.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
|
||||
'.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
|
||||
'[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
|
||||
'[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
|
||||
'[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
|
||||
// isolation:isolate contains artboard content's z-indexes so a
|
||||
// z-indexed child (sticky navbar etc.) can't paint over .dc-header or
|
||||
// the .dc-menu popover that drops into the top of the card.
|
||||
'.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}',
|
||||
'.dc-card *{scrollbar-width:none}',
|
||||
'.dc-card *::-webkit-scrollbar{display:none}',
|
||||
// Per-artboard header: grip + label on the left, delete/expand on the
|
||||
// right. Single flex row; when the artboard's on-screen width is too
|
||||
// narrow for both the label yields (ellipsis, then hidden entirely below
|
||||
// ~4ch via the container query) and the buttons stay on the row.
|
||||
'.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;',
|
||||
' display:flex;align-items:center;container-type:inline-size}',
|
||||
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}',
|
||||
'.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}',
|
||||
'.dc-grip:hover{background:rgba(0,0,0,.08)}',
|
||||
'.dc-grip:active{cursor:grabbing}',
|
||||
'.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;',
|
||||
' display:flex;align-items:center;transition:background .12s;overflow:hidden}',
|
||||
// Below ~4ch of label room: hide the label entirely, and drop the grip to
|
||||
// hover-only (same reveal rule as .dc-btns) so a narrow header is clean
|
||||
// until the card is moused.
|
||||
'@container (max-width: 110px){',
|
||||
' .dc-labeltext{display:none}',
|
||||
' .dc-grip{opacity:0}',
|
||||
' [data-dc-slot]:hover .dc-grip{opacity:1}',
|
||||
'}',
|
||||
'.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
|
||||
'.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}',
|
||||
'.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}',
|
||||
'.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}',
|
||||
'[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}',
|
||||
'.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
|
||||
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;',
|
||||
' font:inherit;transition:background .12s,color .12s}',
|
||||
'.dc-expand:hover,.dc-kebab:hover{background:rgba(0,0,0,.06);color:#2a251f}',
|
||||
// Slot hosting an open menu floats above later siblings (which otherwise
|
||||
// paint on top — same z-index:auto, later DOM order) so the popup isn't
|
||||
// clipped by the next card.
|
||||
'[data-dc-slot]:has(.dc-menu){z-index:10}',
|
||||
'.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;',
|
||||
' box-shadow:0 8px 28px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.05);padding:4px;min-width:160px;z-index:10}',
|
||||
'.dc-menu button{display:block;width:100%;padding:7px 10px;border:0;background:transparent;',
|
||||
' border-radius:5px;font-family:inherit;font-size:13px;font-weight:500;line-height:1.2;',
|
||||
' color:#29261b;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}',
|
||||
'.dc-menu button:hover{background:rgba(0,0,0,.05)}',
|
||||
'.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}',
|
||||
'.dc-menu .dc-danger{color:#c96442}',
|
||||
'.dc-menu .dc-danger:hover{background:rgba(201,100,66,.1)}',
|
||||
// Chrome (titles / labels / buttons) counter-scales against the viewport
|
||||
// zoom so it stays a constant on-screen size. --dc-inv-zoom is set by
|
||||
// DCViewport on every transform update and inherits to all descendants —
|
||||
// any overlay inside the world (e.g. a TweaksPanel on an artboard) can use
|
||||
// it the same way.
|
||||
//
|
||||
// The header uses transform:scale (out-of-flow, so layout impact doesn't
|
||||
// matter) with its world-space width set to card-width / inv-zoom so that
|
||||
// after counter-scaling its on-screen width exactly matches the card's —
|
||||
// that's what lets the container query + text-overflow behave against the
|
||||
// card's visible edge at every zoom level.
|
||||
//
|
||||
// The section head uses CSS zoom instead of transform so its layout box
|
||||
// grows with the counter-scale, pushing the card row down — otherwise the
|
||||
// constant-screen-size title would overflow into the (shrinking) world-
|
||||
// space gap and overlap the artboard headers at low zoom.
|
||||
'.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));',
|
||||
' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}',
|
||||
'.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}',
|
||||
].join('\n');
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
const DCCtx = React.createContext(null);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
|
||||
// Owns runtime state (per-section order, renamed titles/labels, hidden
|
||||
// artboards, focused artboard). Order/titles/labels/hidden persist to a
|
||||
// .design-canvas.state.json
|
||||
// sidecar next to the HTML. Reads go via plain fetch() so the saved
|
||||
// arrangement is visible anywhere the HTML + sidecar are served together
|
||||
// (omelette preview, direct link, downloaded zip). Writes go through the
|
||||
// host's window.omelette bridge — editing requires the omelette runtime.
|
||||
// Focus is ephemeral.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const DC_STATE_FILE = '.design-canvas.state.json';
|
||||
|
||||
function DesignCanvas({ children, minScale, maxScale, style }) {
|
||||
const [state, setState] = React.useState({ sections: {}, focus: null });
|
||||
// Hold rendering until the sidecar read settles so the saved order/titles
|
||||
// appear on first paint (no source-order flash). didRead gates writes until
|
||||
// the read settles so the empty initial state can't clobber a slow read;
|
||||
// skipNextWrite suppresses the one echo-write that would otherwise follow
|
||||
// hydration.
|
||||
const [ready, setReady] = React.useState(false);
|
||||
const didRead = React.useRef(false);
|
||||
const skipNextWrite = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let off = false;
|
||||
fetch('./' + DC_STATE_FILE)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((saved) => {
|
||||
if (off || !saved || !saved.sections) return;
|
||||
skipNextWrite.current = true;
|
||||
setState((s) => ({ ...s, sections: saved.sections }));
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { didRead.current = true; if (!off) setReady(true); });
|
||||
const t = setTimeout(() => { if (!off) setReady(true); }, 150);
|
||||
return () => { off = true; clearTimeout(t); };
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!didRead.current) return;
|
||||
if (skipNextWrite.current) { skipNextWrite.current = false; return; }
|
||||
const t = setTimeout(() => {
|
||||
window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
|
||||
}, 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [state.sections]);
|
||||
|
||||
// Build registries synchronously from children so FocusOverlay can read
|
||||
// them in the same render. Only direct DCSection > DCArtboard children are
|
||||
// walked — wrapping them in other elements opts out of focus/reorder.
|
||||
const registry = {}; // slotId -> { sectionId, artboard }
|
||||
const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
|
||||
const sectionOrder = [];
|
||||
React.Children.forEach(children, (sec) => {
|
||||
if (!sec || sec.type !== DCSection) return;
|
||||
const sid = sec.props.id ?? sec.props.title;
|
||||
if (!sid) return;
|
||||
sectionOrder.push(sid);
|
||||
const persisted = state.sections[sid] || {};
|
||||
const abs = [];
|
||||
React.Children.forEach(sec.props.children, (ab) => {
|
||||
if (!ab || ab.type !== DCArtboard) return;
|
||||
const aid = ab.props.id ?? ab.props.label;
|
||||
if (aid) abs.push([aid, ab]);
|
||||
});
|
||||
// hidden is scoped to one source revision — when the agent regenerates
|
||||
// (artboard-ID set changes), prior deletes don't apply to new content.
|
||||
const srcKey = abs.map(([k]) => k).join('\x1f');
|
||||
const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : [];
|
||||
const srcIds = [];
|
||||
abs.forEach(([aid, ab]) => {
|
||||
if (hidden.includes(aid)) return;
|
||||
registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
|
||||
srcIds.push(aid);
|
||||
});
|
||||
const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
|
||||
sectionMeta[sid] = {
|
||||
title: persisted.title ?? sec.props.title,
|
||||
subtitle: sec.props.subtitle,
|
||||
slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
|
||||
};
|
||||
});
|
||||
|
||||
const api = React.useMemo(() => ({
|
||||
state,
|
||||
section: (id) => state.sections[id] || {},
|
||||
patchSection: (id, p) => setState((s) => ({
|
||||
...s,
|
||||
sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
|
||||
})),
|
||||
setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
|
||||
}), [state]);
|
||||
|
||||
// Esc exits focus; any outside pointerdown commits an in-progress rename.
|
||||
React.useEffect(() => {
|
||||
const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
|
||||
const onPd = (e) => {
|
||||
const ae = document.activeElement;
|
||||
if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.addEventListener('pointerdown', onPd, true);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.removeEventListener('pointerdown', onPd, true);
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<DCCtx.Provider value={api}>
|
||||
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
|
||||
{state.focus && registry[state.focus] && (
|
||||
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
|
||||
)}
|
||||
</DCCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DCViewport — transform-based pan/zoom (internal)
|
||||
//
|
||||
// Input mapping (Figma-style):
|
||||
// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
|
||||
// • trackpad scroll → pan (two-finger)
|
||||
// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
|
||||
// • middle-drag / primary-drag-on-bg → pan
|
||||
//
|
||||
// Transform state lives in a ref and is written straight to the DOM
|
||||
// (translate3d + will-change) so wheel ticks don't go through React —
|
||||
// keeps pans at 60fps on dense canvases.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
|
||||
const vpRef = React.useRef(null);
|
||||
const worldRef = React.useRef(null);
|
||||
const tf = React.useRef({ x: 0, y: 0, scale: 1 });
|
||||
// Persist viewport across reloads so the user lands back where they were
|
||||
// after an agent edit or browser refresh. The sandbox origin is already
|
||||
// per-project; pathname keeps multiple canvas files in one project apart.
|
||||
const tfKey = 'dc-viewport:' + location.pathname;
|
||||
const saveT = React.useRef(0);
|
||||
|
||||
const lastPostedScale = React.useRef();
|
||||
const apply = React.useCallback(() => {
|
||||
const { x, y, scale } = tf.current;
|
||||
const el = worldRef.current;
|
||||
if (!el) return;
|
||||
el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
|
||||
// Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel).
|
||||
el.style.setProperty('--dc-inv-zoom', String(1 / scale));
|
||||
// Keep the host toolbar's % readout in sync with the canvas scale. Pan
|
||||
// ticks leave scale unchanged — skip the cross-frame post for those.
|
||||
if (lastPostedScale.current !== scale) {
|
||||
lastPostedScale.current = scale;
|
||||
window.parent.postMessage({ type: '__dc_zoom', scale }, '*');
|
||||
}
|
||||
clearTimeout(saveT.current);
|
||||
saveT.current = setTimeout(() => {
|
||||
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
|
||||
}, 200);
|
||||
}, [tfKey]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const flush = () => {
|
||||
clearTimeout(saveT.current);
|
||||
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
|
||||
};
|
||||
try {
|
||||
const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
|
||||
if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) {
|
||||
tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) };
|
||||
apply();
|
||||
}
|
||||
} catch {}
|
||||
// Flush on pagehide and unmount so a reload within the 200ms debounce
|
||||
// window doesn't drop the last pan/zoom.
|
||||
window.addEventListener('pagehide', flush);
|
||||
return () => { window.removeEventListener('pagehide', flush); flush(); };
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const vp = vpRef.current;
|
||||
if (!vp) return;
|
||||
|
||||
const zoomAt = (cx, cy, factor) => {
|
||||
const r = vp.getBoundingClientRect();
|
||||
const px = cx - r.left, py = cy - r.top;
|
||||
const t = tf.current;
|
||||
const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
|
||||
const k = next / t.scale;
|
||||
// keep the world point under the cursor fixed
|
||||
t.x = px - (px - t.x) * k;
|
||||
t.y = py - (py - t.y) * k;
|
||||
t.scale = next;
|
||||
apply();
|
||||
};
|
||||
|
||||
// Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
|
||||
// line-mode deltas (Firefox) or large integer pixel deltas with no X
|
||||
// component (Chrome/Safari, typically multiples of 100/120). Trackpad
|
||||
// two-finger scroll sends small/fractional pixel deltas, often with
|
||||
// non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
|
||||
const isMouseWheel = (e) =>
|
||||
e.deltaMode !== 0 ||
|
||||
(e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
|
||||
|
||||
const onWheel = (e) => {
|
||||
e.preventDefault();
|
||||
if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
|
||||
if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) {
|
||||
// trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched
|
||||
// wheels fall through to the fixed-step branch below.
|
||||
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
|
||||
} else if (isMouseWheel(e)) {
|
||||
// notched mouse wheel — fixed-ratio step per click
|
||||
zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
|
||||
} else {
|
||||
// trackpad two-finger scroll — pan
|
||||
tf.current.x -= e.deltaX;
|
||||
tf.current.y -= e.deltaY;
|
||||
apply();
|
||||
}
|
||||
};
|
||||
|
||||
// Safari sends native gesture* events for trackpad pinch with a smooth
|
||||
// e.scale; preferring these over the ctrl+wheel fallback gives a much
|
||||
// better feel there. No-ops on other browsers. Safari also fires
|
||||
// ctrlKey wheel events during the same pinch — isGesturing makes
|
||||
// onWheel drop those entirely so they neither zoom nor pan.
|
||||
let gsBase = 1;
|
||||
let isGesturing = false;
|
||||
const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
|
||||
const onGestureChange = (e) => {
|
||||
e.preventDefault();
|
||||
zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
|
||||
};
|
||||
const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
|
||||
|
||||
// Drag-pan: middle button anywhere, or primary button on canvas
|
||||
// background (anything that isn't an artboard or an inline editor).
|
||||
let drag = null;
|
||||
const onPointerDown = (e) => {
|
||||
const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
|
||||
if (!(e.button === 1 || (e.button === 0 && onBg))) return;
|
||||
e.preventDefault();
|
||||
vp.setPointerCapture(e.pointerId);
|
||||
drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
|
||||
vp.style.cursor = 'grabbing';
|
||||
};
|
||||
const onPointerMove = (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
tf.current.x += e.clientX - drag.lx;
|
||||
tf.current.y += e.clientY - drag.ly;
|
||||
drag.lx = e.clientX; drag.ly = e.clientY;
|
||||
apply();
|
||||
};
|
||||
const onPointerUp = (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
vp.releasePointerCapture(e.pointerId);
|
||||
drag = null;
|
||||
vp.style.cursor = '';
|
||||
};
|
||||
|
||||
// Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
|
||||
// visible midpoint stays fixed — matching the host's iframe-zoom feel.
|
||||
const onHostMsg = (e) => {
|
||||
const d = e.data;
|
||||
if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') {
|
||||
const r = vp.getBoundingClientRect();
|
||||
zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale);
|
||||
} else if (d && d.type === '__dc_probe') {
|
||||
// Host's [readyGen] reset asks whether a canvas is present; it
|
||||
// fires on the iframe's native 'load', which for canvases with
|
||||
// images/fonts is after our mount-time announce, so re-announce.
|
||||
// Clear the pan-tick guard so apply() re-posts the current scale
|
||||
// even if it's unchanged — the host just reset dcScale to 1.
|
||||
window.parent.postMessage({ type: '__dc_present' }, '*');
|
||||
lastPostedScale.current = undefined;
|
||||
apply();
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', onHostMsg);
|
||||
// Announce canvas mode so the host toolbar proxies its % control here
|
||||
// instead of scaling the iframe element (which would just shrink the
|
||||
// viewport window of an infinite canvas). The apply() that follows emits
|
||||
// the initial __dc_zoom so the toolbar % is correct before first pinch.
|
||||
// lastPostedScale reset mirrors the __dc_probe handler: the layout
|
||||
// effect's restore-path apply() may already have posted the restored
|
||||
// scale (before __dc_present), so clear the guard to re-post it in order.
|
||||
window.parent.postMessage({ type: '__dc_present' }, '*');
|
||||
lastPostedScale.current = undefined;
|
||||
apply();
|
||||
|
||||
vp.addEventListener('wheel', onWheel, { passive: false });
|
||||
vp.addEventListener('gesturestart', onGestureStart, { passive: false });
|
||||
vp.addEventListener('gesturechange', onGestureChange, { passive: false });
|
||||
vp.addEventListener('gestureend', onGestureEnd, { passive: false });
|
||||
vp.addEventListener('pointerdown', onPointerDown);
|
||||
vp.addEventListener('pointermove', onPointerMove);
|
||||
vp.addEventListener('pointerup', onPointerUp);
|
||||
vp.addEventListener('pointercancel', onPointerUp);
|
||||
return () => {
|
||||
window.removeEventListener('message', onHostMsg);
|
||||
vp.removeEventListener('wheel', onWheel);
|
||||
vp.removeEventListener('gesturestart', onGestureStart);
|
||||
vp.removeEventListener('gesturechange', onGestureChange);
|
||||
vp.removeEventListener('gestureend', onGestureEnd);
|
||||
vp.removeEventListener('pointerdown', onPointerDown);
|
||||
vp.removeEventListener('pointermove', onPointerMove);
|
||||
vp.removeEventListener('pointerup', onPointerUp);
|
||||
vp.removeEventListener('pointercancel', onPointerUp);
|
||||
};
|
||||
}, [apply, minScale, maxScale]);
|
||||
|
||||
const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
|
||||
return (
|
||||
<div
|
||||
ref={vpRef}
|
||||
className="design-canvas"
|
||||
style={{
|
||||
height: '100vh', width: '100vw',
|
||||
background: DC.bg,
|
||||
overflow: 'hidden',
|
||||
overscrollBehavior: 'none',
|
||||
touchAction: 'none',
|
||||
position: 'relative',
|
||||
fontFamily: DC.font,
|
||||
boxSizing: 'border-box',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={worldRef}
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0,
|
||||
transformOrigin: '0 0',
|
||||
willChange: 'transform',
|
||||
width: 'max-content', minWidth: '100%',
|
||||
minHeight: '100%',
|
||||
padding: '60px 0 80px',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DCSection — editable title + h-row of artboards in persisted order
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCSection({ id, title, subtitle, children, gap = 48 }) {
|
||||
const ctx = React.useContext(DCCtx);
|
||||
const sid = id ?? title;
|
||||
const all = React.Children.toArray(children);
|
||||
const artboards = all.filter((c) => c && c.type === DCArtboard);
|
||||
const rest = all.filter((c) => !(c && c.type === DCArtboard));
|
||||
const sec = (ctx && sid && ctx.section(sid)) || {};
|
||||
// Must match DesignCanvas's srcKey computation exactly (it filters falsy
|
||||
// IDs), or onDelete persists a srcKey that DesignCanvas never recognizes.
|
||||
const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean);
|
||||
const srcKey = allIds.join('\x1f');
|
||||
const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : [];
|
||||
const srcOrder = allIds.filter((k) => !hidden.includes(k));
|
||||
|
||||
const order = React.useMemo(() => {
|
||||
const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
|
||||
return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
|
||||
}, [sec.order, srcOrder.join('|')]);
|
||||
|
||||
const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
|
||||
|
||||
// marginBottom counter-scales so the on-screen gap between sections stays
|
||||
// constant — otherwise at low zoom the (world-space) gap collapses while
|
||||
// the screen-constant sectionhead below it doesn't, and the title reads as
|
||||
// belonging to the section above. paddingBottom below is just enough for
|
||||
// the 24px artboard-header (abs-positioned above each card) plus ~8px, so
|
||||
// the title sits tight against its own row at every zoom.
|
||||
return (
|
||||
<div data-dc-section={sid}
|
||||
style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
|
||||
<div style={{ padding: '0 60px' }}>
|
||||
<div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
|
||||
<DCEditable tag="div" value={sec.title ?? title}
|
||||
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
|
||||
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
|
||||
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
|
||||
{order.map((k) => (
|
||||
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
|
||||
label={(sec.labels || {})[k] ?? byId[k].props.label}
|
||||
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
|
||||
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
|
||||
onDelete={() => ctx && ctx.patchSection(sid, (x) => ({
|
||||
hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k],
|
||||
srcKey,
|
||||
}))}
|
||||
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
|
||||
))}
|
||||
</div>
|
||||
{rest}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
|
||||
function DCArtboard() { return null; }
|
||||
|
||||
// Per-artboard export (kind: 'png' | 'html'). Both paths share the same
|
||||
// self-contained clone: computed styles baked in, @font-face / <img> /
|
||||
// inline-style background-image urls inlined as data URIs. PNG wraps the
|
||||
// clone in foreignObject→canvas at 3× the artboard's natural width×height
|
||||
// (same pipeline the host uses for page captures); HTML wraps it in a
|
||||
// minimal standalone document. Both are independent of viewport zoom.
|
||||
async function dcExport(node, w, h, name, kind) {
|
||||
try { await document.fonts.ready; } catch {}
|
||||
const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => {
|
||||
const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b);
|
||||
})).catch(() => url);
|
||||
|
||||
// Collect @font-face rules. ss.cssRules throws SecurityError on
|
||||
// cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch
|
||||
// the CSS text directly (those endpoints send ACAO:*) and regex-extract
|
||||
// the blocks. @import and @media/@supports are walked so nested
|
||||
// @font-face rules aren't missed.
|
||||
const fontRules = [], pending = [], seen = new Set();
|
||||
const scrapeCss = (href) => {
|
||||
if (seen.has(href)) return; seen.add(href);
|
||||
pending.push(fetch(href).then((r) => r.text()).then((css) => {
|
||||
for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href });
|
||||
for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g))
|
||||
scrapeCss(new URL(m[1], href).href);
|
||||
}).catch(() => {}));
|
||||
};
|
||||
const walk = (rules, base) => {
|
||||
for (const r of rules) {
|
||||
if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base });
|
||||
else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) {
|
||||
const ibase = r.styleSheet.href || base;
|
||||
try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); }
|
||||
} else if (r.cssRules) walk(r.cssRules, base);
|
||||
}
|
||||
};
|
||||
for (const ss of document.styleSheets) {
|
||||
const base = ss.href || location.href;
|
||||
try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); }
|
||||
}
|
||||
while (pending.length) await pending.shift();
|
||||
const fontCss = (await Promise.all(fontRules.map(async (rule) => {
|
||||
let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g;
|
||||
while ((m = re.exec(rule.css))) {
|
||||
if (m[2].indexOf('data:') === 0) continue;
|
||||
let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; }
|
||||
out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")');
|
||||
}
|
||||
return out;
|
||||
}))).join('\n');
|
||||
|
||||
const cloneStyled = (src) => {
|
||||
if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode('');
|
||||
const dst = src.cloneNode(false);
|
||||
if (src.nodeType === 1) {
|
||||
const cs = getComputedStyle(src); let txt = '';
|
||||
for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';';
|
||||
dst.setAttribute('style', txt + 'animation:none;transition:none;');
|
||||
if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {}
|
||||
}
|
||||
for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c));
|
||||
return dst;
|
||||
};
|
||||
const clone = cloneStyled(node);
|
||||
clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
|
||||
// Drop the card's own shadow/radius so the export is a flush w×h rect;
|
||||
// the artboard's own background (if any) is already in the computed style.
|
||||
clone.style.boxShadow = 'none'; clone.style.borderRadius = '0';
|
||||
|
||||
const jobs = [];
|
||||
clone.querySelectorAll('img').forEach((el) => {
|
||||
const s = el.getAttribute('src');
|
||||
if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d)));
|
||||
});
|
||||
[clone, ...clone.querySelectorAll('*')].forEach((el) => {
|
||||
const bg = el.style.backgroundImage; if (!bg) return;
|
||||
let m; const re = /url\(["']?([^"')]+)["']?\)/g;
|
||||
while ((m = re.exec(bg))) {
|
||||
const tok = m[0], url = m[1];
|
||||
if (url.indexOf('data:') === 0) continue;
|
||||
jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); }));
|
||||
}
|
||||
});
|
||||
await Promise.all(jobs);
|
||||
|
||||
const xml = new XMLSerializer().serializeToString(clone);
|
||||
const save = (blob, ext) => {
|
||||
if (!blob) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
};
|
||||
|
||||
if (kind === 'html') {
|
||||
const html = '<!doctype html><html><head><meta charset="utf-8"><title>' + name + '</title>' +
|
||||
(fontCss ? '<style>' + fontCss + '</style>' : '') +
|
||||
'</head><body style="margin:0">' + xml + '</body></html>';
|
||||
return save(new Blob([html], { type: 'text/html' }), 'html');
|
||||
}
|
||||
|
||||
// PNG: the SVG's own width/height must be the output resolution — an
|
||||
// <img>-loaded SVG rasterizes at its intrinsic size, so sizing it at 1×
|
||||
// and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the
|
||||
// w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders
|
||||
// the HTML at full resolution.
|
||||
const px = 3;
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w * px + '" height="' + h * px +
|
||||
'" viewBox="0 0 ' + w + ' ' + h + '"><foreignObject width="' + w + '" height="' + h + '">' +
|
||||
(fontCss ? '<style><![CDATA[' + fontCss + ']]></style>' : '') + xml + '</foreignObject></svg>';
|
||||
const img = new Image();
|
||||
await new Promise((res, rej) => {
|
||||
img.onload = res; img.onerror = () => rej(new Error('svg load failed'));
|
||||
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
||||
});
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = w * px; cv.height = h * px;
|
||||
cv.getContext('2d').drawImage(img, 0, 0);
|
||||
cv.toBlob((blob) => save(blob, 'png'), 'image/png');
|
||||
}
|
||||
|
||||
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) {
|
||||
const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
|
||||
const id = rawId ?? rawLabel;
|
||||
const ref = React.useRef(null);
|
||||
const cardRef = React.useRef(null);
|
||||
const menuRef = React.useRef(null);
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const [confirming, setConfirming] = React.useState(false);
|
||||
|
||||
// ⋯ menu: close on any outside pointerdown. Two-click delete lives inside
|
||||
// the menu — first click arms the row, second commits; closing disarms.
|
||||
React.useEffect(() => {
|
||||
if (!menuOpen) { setConfirming(false); return; }
|
||||
const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); };
|
||||
document.addEventListener('pointerdown', off, true);
|
||||
return () => document.removeEventListener('pointerdown', off, true);
|
||||
}, [menuOpen]);
|
||||
|
||||
const doExport = (kind) => {
|
||||
setMenuOpen(false);
|
||||
if (!cardRef.current) return;
|
||||
const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_');
|
||||
dcExport(cardRef.current, width, height, name, kind)
|
||||
.catch((e) => console.error('[design-canvas] export failed:', e));
|
||||
};
|
||||
|
||||
// Live drag-reorder: dragged card sticks to cursor; siblings slide into
|
||||
// their would-be slots in real time via transforms. DOM order only
|
||||
// changes on drop.
|
||||
const onGripDown = (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const me = ref.current;
|
||||
// translateX is applied in local (pre-scale) space but pointer deltas and
|
||||
// getBoundingClientRect().left are screen-space — divide by the viewport's
|
||||
// current scale so the dragged card tracks the cursor at any zoom level.
|
||||
const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
|
||||
const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
|
||||
const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
|
||||
const slotXs = homes.map((h) => h.x);
|
||||
const startIdx = order.indexOf(id);
|
||||
const startX = e.clientX;
|
||||
let liveOrder = order.slice();
|
||||
me.classList.add('dc-dragging');
|
||||
|
||||
const layout = () => {
|
||||
for (const h of homes) {
|
||||
if (h.id === id) continue;
|
||||
const slot = liveOrder.indexOf(h.id);
|
||||
h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
|
||||
}
|
||||
};
|
||||
|
||||
const move = (ev) => {
|
||||
const dx = ev.clientX - startX;
|
||||
me.style.transform = `translateX(${dx / scale}px)`;
|
||||
const cur = homes[startIdx].x + dx;
|
||||
let nearest = 0, best = Infinity;
|
||||
for (let i = 0; i < slotXs.length; i++) {
|
||||
const d = Math.abs(slotXs[i] - cur);
|
||||
if (d < best) { best = d; nearest = i; }
|
||||
}
|
||||
if (liveOrder.indexOf(id) !== nearest) {
|
||||
liveOrder = order.filter((k) => k !== id);
|
||||
liveOrder.splice(nearest, 0, id);
|
||||
layout();
|
||||
}
|
||||
};
|
||||
|
||||
const up = () => {
|
||||
document.removeEventListener('pointermove', move);
|
||||
document.removeEventListener('pointerup', up);
|
||||
const finalSlot = liveOrder.indexOf(id);
|
||||
me.classList.remove('dc-dragging');
|
||||
me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
|
||||
// After the settle transition, kill transitions + clear transforms +
|
||||
// commit the reorder in the same frame so there's no visual snap-back.
|
||||
setTimeout(() => {
|
||||
for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
|
||||
if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
for (const h of homes) h.el.style.transition = '';
|
||||
}));
|
||||
}, 180);
|
||||
};
|
||||
document.addEventListener('pointermove', move);
|
||||
document.addEventListener('pointerup', up);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<div className="dc-header" style={{ color: DC.label }} onPointerDown={(e) => e.stopPropagation()}>
|
||||
<div className="dc-labelrow">
|
||||
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
|
||||
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
|
||||
</div>
|
||||
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
|
||||
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
|
||||
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="dc-btns">
|
||||
<div ref={menuRef} style={{ position: 'relative' }}>
|
||||
<button className="dc-kebab" title="More" onClick={() => setMenuOpen((o) => !o)}>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><circle cx="2.5" cy="6" r="1.1"/><circle cx="6" cy="6" r="1.1"/><circle cx="9.5" cy="6" r="1.1"/></svg>
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="dc-menu" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<button onClick={() => doExport('png')}>Download PNG</button>
|
||||
<button onClick={() => doExport('html')}>Download HTML</button>
|
||||
<hr />
|
||||
<button className="dc-danger"
|
||||
onClick={() => { if (confirming) { setMenuOpen(false); onDelete(); } else setConfirming(true); }}>
|
||||
{confirming ? 'Click again to delete' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="dc-expand" onClick={onFocus} title="Focus">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={cardRef} className="dc-card"
|
||||
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
|
||||
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline rename — commits on blur or Enter.
|
||||
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
|
||||
const T = tag;
|
||||
return (
|
||||
<T className="dc-editable" contentEditable suppressContentEditableWarning
|
||||
onClick={onClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
|
||||
style={style}>{value}</T>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
|
||||
// sections, Esc or backdrop click to exit.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
|
||||
const ctx = React.useContext(DCCtx);
|
||||
const { sectionId, artboard } = entry;
|
||||
const sec = ctx.section(sectionId);
|
||||
const meta = sectionMeta[sectionId];
|
||||
const peers = meta.slotIds;
|
||||
const aid = artboard.props.id ?? artboard.props.label;
|
||||
const idx = peers.indexOf(aid);
|
||||
const secIdx = sectionOrder.indexOf(sectionId);
|
||||
|
||||
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
|
||||
const goSection = (d) => {
|
||||
// Sections whose artboards are all deleted have slotIds:[] — step past
|
||||
// them to the next non-empty section so ↑/↓ doesn't dead-end.
|
||||
const n = sectionOrder.length;
|
||||
for (let i = 1; i < n; i++) {
|
||||
const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n];
|
||||
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
|
||||
if (first) { ctx.setFocus(`${ns}/${first}`); return; }
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const k = (e) => {
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
|
||||
};
|
||||
document.addEventListener('keydown', k);
|
||||
return () => document.removeEventListener('keydown', k);
|
||||
});
|
||||
|
||||
const { width = 260, height = 480, children } = artboard.props;
|
||||
const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
|
||||
React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
|
||||
const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
|
||||
|
||||
const [ddOpen, setDd] = React.useState(false);
|
||||
const Arrow = ({ dir, onClick }) => (
|
||||
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
|
||||
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
|
||||
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
|
||||
</button>
|
||||
);
|
||||
|
||||
// Portal to body so position:fixed is the real viewport regardless of any
|
||||
// transform on DesignCanvas's ancestors (including the canvas zoom itself).
|
||||
return ReactDOM.createPortal(
|
||||
<div onClick={() => ctx.setFocus(null)}
|
||||
onWheel={(e) => e.preventDefault()}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
|
||||
fontFamily: DC.font, color: '#fff' }}>
|
||||
|
||||
{/* top bar: section dropdown (left) · close (right) */}
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button onClick={() => setDd((o) => !o)}
|
||||
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
|
||||
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
|
||||
</span>
|
||||
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
|
||||
</button>
|
||||
{ddOpen && (
|
||||
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
|
||||
{sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => (
|
||||
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
|
||||
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
|
||||
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
|
||||
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
|
||||
{sectionMeta[sid].title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button onClick={() => ctx.setFocus(null)}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
|
||||
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
|
||||
</div>
|
||||
|
||||
{/* card centered, label + index below — only the card itself stops
|
||||
propagation so any backdrop click (including the margins around
|
||||
the card) exits focus */}
|
||||
<div
|
||||
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
|
||||
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
|
||||
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
|
||||
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
|
||||
{(sec.labels || {})[aid] ?? artboard.props.label}
|
||||
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Arrow dir="left" onClick={() => go(-1)} />
|
||||
<Arrow dir="right" onClick={() => go(1)} />
|
||||
|
||||
{/* dots */}
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
|
||||
{peers.map((p, i) => (
|
||||
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
|
||||
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
|
||||
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Post-it — absolute-positioned sticky note
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', top, left, right, bottom, width,
|
||||
background: DC.postitBg, padding: '14px 16px',
|
||||
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
|
||||
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
|
||||
transform: `rotate(${rotate}deg)`,
|
||||
zIndex: 5,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Keysat — Logo directions v2</title>
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { margin: 0; font-family: 'Manrope', sans-serif; background: var(--cream-100); }
|
||||
.ab { background: #FBF9F2; padding: 32px 36px; box-sizing: border-box; min-height: 100%; position: relative; background-image: radial-gradient(rgba(14,31,51,0.022) 1px, transparent 1px), radial-gradient(rgba(138,111,61,0.020) 1px, transparent 1px); background-size: 3px 3px, 7px 7px; }
|
||||
.label-tag { position: absolute; top: 14px; right: 18px; font-family: 'JetBrains Mono', monospace; font-size: 9.5px; color: #8A6F3D; letter-spacing: 0.18em; text-transform: uppercase; }
|
||||
.name { font-family: 'Manrope', sans-serif; font-weight: 600; font-size: 13px; letter-spacing: -0.01em; color: #0E1F33; margin-bottom: 20px; }
|
||||
.name span { font-weight: 400; color: #8A6F3D; margin-left: 8px; font-size: 11px; }
|
||||
.row { display: flex; align-items: center; gap: 28px; padding: 20px; background: rgba(245,241,232,0.55); border: 1px solid rgba(14,31,51,0.08); border-radius: 8px; }
|
||||
.row .marks { display: flex; align-items: center; gap: 16px; }
|
||||
.row .lock { display: flex; align-items: center; gap: 10px; padding-left: 22px; border-left: 1px solid rgba(14,31,51,0.12); }
|
||||
.row .lock .wm { font-family: 'Manrope', sans-serif; font-weight: 500; font-size: 22px; letter-spacing: 0.32em; color: #1E3A5F; text-transform: uppercase; }
|
||||
.scenarios { margin-top: 14px; display: flex; gap: 14px; align-items: center; flex-wrap: wrap; }
|
||||
.scenarios .browser-tab { display: flex; align-items: center; gap: 6px; padding: 6px 12px; background: rgba(14,31,51,0.06); border-radius: 6px 6px 0 0; font-size: 11px; color: #2C3E54; }
|
||||
.scenarios .dark { background: #0E1F33; padding: 7px 12px; border-radius: 6px; display: flex; align-items: center; gap: 8px; color: #FBF9F2; font-family: 'Manrope'; font-weight: 500; font-size: 11.5px; letter-spacing: 0.18em; text-transform: uppercase; }
|
||||
.scenarios .chip { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: #FBF9F2; border: 1px solid rgba(14,31,51,0.12); border-radius: 999px; font-size: 11.5px; color: #2C3E54; }
|
||||
.pros { margin-top: 14px; font-size: 12px; color: #5A6B7F; line-height: 1.6; padding-top: 12px; border-top: 1px dashed rgba(14,31,51,0.15); }
|
||||
.pros strong { color: #0E1F33; font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- 1. SCROLL with key -->
|
||||
<template id="logo-1">
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Scroll body -->
|
||||
<path d="M22 22 L78 22 L78 78 L22 78 Z" fill="#FBF9F2" stroke="#1E3A5F" stroke-width="3"/>
|
||||
<!-- Top scroll roll -->
|
||||
<ellipse cx="50" cy="22" rx="28" ry="5" fill="#1E3A5F"/>
|
||||
<!-- Bottom scroll roll -->
|
||||
<ellipse cx="50" cy="78" rx="28" ry="5" fill="#1E3A5F"/>
|
||||
<!-- Text lines -->
|
||||
<line x1="32" y1="36" x2="68" y2="36" stroke="#1E3A5F" stroke-width="1.5"/>
|
||||
<line x1="32" y1="44" x2="62" y2="44" stroke="#1E3A5F" stroke-width="1.5"/>
|
||||
<!-- Key (centered, lower portion) -->
|
||||
<circle cx="42" cy="60" r="6" fill="none" stroke="#BFA068" stroke-width="2.5"/>
|
||||
<rect x="48" y="58.5" width="14" height="3" fill="#BFA068"/>
|
||||
<rect x="58" y="61.5" width="2" height="4" fill="#BFA068"/>
|
||||
<rect x="62" y="61.5" width="2" height="3" fill="#BFA068"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<!-- 2. CERTIFICATE / DEED -->
|
||||
<template id="logo-2">
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Certificate paper -->
|
||||
<rect x="18" y="20" width="58" height="64" fill="#FBF9F2" stroke="#1E3A5F" stroke-width="2.5"/>
|
||||
<!-- Inner gold border -->
|
||||
<rect x="22" y="24" width="50" height="56" fill="none" stroke="#BFA068" stroke-width="0.75"/>
|
||||
<!-- Header lines -->
|
||||
<line x1="30" y1="34" x2="64" y2="34" stroke="#1E3A5F" stroke-width="2"/>
|
||||
<line x1="34" y1="42" x2="60" y2="42" stroke="#1E3A5F" stroke-width="1"/>
|
||||
<!-- Wax seal in lower-right, overlapping certificate edge -->
|
||||
<circle cx="74" cy="68" r="14" fill="#1E3A5F"/>
|
||||
<circle cx="74" cy="68" r="11" fill="none" stroke="#BFA068" stroke-width="0.75"/>
|
||||
<!-- Tiny key in seal -->
|
||||
<circle cx="71" cy="68" r="3" fill="none" stroke="#BFA068" stroke-width="1.5"/>
|
||||
<rect x="74" y="67.25" width="6" height="1.5" fill="#BFA068"/>
|
||||
<rect x="78" y="68.75" width="1" height="2" fill="#BFA068"/>
|
||||
<!-- Ribbons under seal -->
|
||||
<path d="M68 80 L66 90 L72 86 Z" fill="#BFA068"/>
|
||||
<path d="M80 80 L82 90 L76 86 Z" fill="#BFA068"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<!-- 3. WINDOW with key -->
|
||||
<template id="logo-3">
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Application window frame -->
|
||||
<rect x="14" y="20" width="72" height="60" rx="4" fill="#1E3A5F"/>
|
||||
<!-- Title bar dots -->
|
||||
<circle cx="20" cy="26" r="1.5" fill="#BFA068"/>
|
||||
<circle cx="25" cy="26" r="1.5" fill="rgba(245,241,232,0.5)"/>
|
||||
<circle cx="30" cy="26" r="1.5" fill="rgba(245,241,232,0.5)"/>
|
||||
<!-- Window content area -->
|
||||
<rect x="18" y="32" width="64" height="44" rx="2" fill="#FBF9F2"/>
|
||||
<!-- Centered key -->
|
||||
<circle cx="42" cy="54" r="8" fill="none" stroke="#1E3A5F" stroke-width="3"/>
|
||||
<circle cx="42" cy="54" r="2.5" fill="#FBF9F2"/>
|
||||
<rect x="50" y="52" width="20" height="4" fill="#1E3A5F"/>
|
||||
<rect x="62" y="56" width="3" height="6" fill="#1E3A5F"/>
|
||||
<rect x="67" y="56" width="3" height="4" fill="#1E3A5F"/>
|
||||
<!-- Gold accent line under window -->
|
||||
<rect x="18" y="78" width="64" height="2" fill="#BFA068"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<!-- 4. RECEIPT / TICKET STUB -->
|
||||
<template id="logo-4">
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Receipt body with zigzag bottom -->
|
||||
<path d="M22 14 L78 14 L78 78 L74 82 L70 78 L66 82 L62 78 L58 82 L54 78 L50 82 L46 78 L42 82 L38 78 L34 82 L30 78 L26 82 L22 78 Z" fill="#FBF9F2" stroke="#1E3A5F" stroke-width="2.5"/>
|
||||
<!-- Header bar -->
|
||||
<rect x="22" y="14" width="56" height="10" fill="#1E3A5F"/>
|
||||
<!-- Lines -->
|
||||
<line x1="30" y1="34" x2="70" y2="34" stroke="#1E3A5F" stroke-width="1.25"/>
|
||||
<line x1="30" y1="40" x2="60" y2="40" stroke="#1E3A5F" stroke-width="1.25"/>
|
||||
<line x1="30" y1="46" x2="65" y2="46" stroke="#1E3A5F" stroke-width="1.25"/>
|
||||
<!-- Perforation -->
|
||||
<line x1="22" y1="56" x2="78" y2="56" stroke="#8A6F3D" stroke-width="0.75" stroke-dasharray="2 2"/>
|
||||
<!-- Key in stub -->
|
||||
<circle cx="38" cy="68" r="5" fill="none" stroke="#BFA068" stroke-width="2"/>
|
||||
<rect x="43" y="67" width="14" height="2.5" fill="#BFA068"/>
|
||||
<rect x="53" y="69.5" width="1.5" height="3.5" fill="#BFA068"/>
|
||||
<rect x="57" y="69.5" width="1.5" height="2.5" fill="#BFA068"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<!-- 5. STAMP / NOTARY MARK -->
|
||||
<template id="logo-5">
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Outer stamp ring (slightly imperfect to feel inked) -->
|
||||
<circle cx="50" cy="50" r="36" fill="none" stroke="#1E3A5F" stroke-width="3"/>
|
||||
<circle cx="50" cy="50" r="28" fill="none" stroke="#1E3A5F" stroke-width="1"/>
|
||||
<!-- Curved text along top (Keysat dots) -->
|
||||
<g fill="#1E3A5F">
|
||||
<circle cx="28" cy="32" r="1"/>
|
||||
<circle cx="50" cy="22" r="1"/>
|
||||
<circle cx="72" cy="32" r="1"/>
|
||||
<circle cx="28" cy="68" r="1"/>
|
||||
<circle cx="50" cy="78" r="1"/>
|
||||
<circle cx="72" cy="68" r="1"/>
|
||||
</g>
|
||||
<!-- LICENSED text top -->
|
||||
<text x="50" y="42" text-anchor="middle" font-family="Manrope" font-weight="600" font-size="6.5" fill="#1E3A5F" letter-spacing="1.5">LICENSED</text>
|
||||
<!-- Center key (horizontal) -->
|
||||
<g>
|
||||
<circle cx="38" cy="55" r="4.5" fill="none" stroke="#BFA068" stroke-width="2"/>
|
||||
<rect x="42" y="54" width="20" height="2.5" fill="#BFA068"/>
|
||||
<rect x="56" y="56.5" width="1.5" height="3.5" fill="#BFA068"/>
|
||||
<rect x="60" y="56.5" width="1.5" height="2.5" fill="#BFA068"/>
|
||||
</g>
|
||||
<!-- Year / mark below -->
|
||||
<text x="50" y="72" text-anchor="middle" font-family="JetBrains Mono" font-weight="600" font-size="5" fill="#1E3A5F" letter-spacing="1">ED25519</text>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<!-- 6. KEY THROUGH BRACKETS -->
|
||||
<template id="logo-6">
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Left angle bracket -->
|
||||
<path d="M30 24 L18 50 L30 76" stroke="#1E3A5F" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
<!-- Right angle bracket -->
|
||||
<path d="M70 24 L82 50 L70 76" stroke="#1E3A5F" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
<!-- Key, centered -->
|
||||
<circle cx="42" cy="50" r="8" fill="none" stroke="#BFA068" stroke-width="3"/>
|
||||
<circle cx="42" cy="50" r="2.5" fill="#FBF9F2"/>
|
||||
<rect x="50" y="48" width="14" height="4" fill="#BFA068"/>
|
||||
<rect x="58" y="52" width="2.5" height="6" fill="#BFA068"/>
|
||||
<rect x="62" y="52" width="2.5" height="4" fill="#BFA068"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<!-- 7. ENVELOPE / SEALED LETTER -->
|
||||
<template id="logo-7">
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Envelope body -->
|
||||
<rect x="14" y="28" width="72" height="50" fill="#FBF9F2" stroke="#1E3A5F" stroke-width="2.5"/>
|
||||
<!-- Envelope flap -->
|
||||
<path d="M14 28 L50 56 L86 28 Z" fill="#1E3A5F"/>
|
||||
<!-- Inner gold line -->
|
||||
<rect x="17" y="31" width="66" height="44" fill="none" stroke="#BFA068" stroke-width="0.5"/>
|
||||
<!-- Wax seal on flap -->
|
||||
<circle cx="50" cy="58" r="9" fill="#BFA068"/>
|
||||
<!-- Key inside seal -->
|
||||
<circle cx="46.5" cy="58" r="2.5" fill="none" stroke="#1E3A5F" stroke-width="1.5"/>
|
||||
<rect x="49" y="57.25" width="6" height="1.5" fill="#1E3A5F"/>
|
||||
<rect x="53" y="58.75" width="1" height="2" fill="#1E3A5F"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<!-- 8. KEYHOLE in document -->
|
||||
<template id="logo-8">
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Document with corner fold -->
|
||||
<path d="M22 14 L66 14 L80 28 L80 86 L22 86 Z" fill="#FBF9F2" stroke="#1E3A5F" stroke-width="2.5"/>
|
||||
<!-- Folded corner -->
|
||||
<path d="M66 14 L66 28 L80 28 Z" fill="#1E3A5F"/>
|
||||
<!-- Header line -->
|
||||
<line x1="30" y1="42" x2="72" y2="42" stroke="#1E3A5F" stroke-width="2"/>
|
||||
<line x1="30" y1="48" x2="64" y2="48" stroke="#1E3A5F" stroke-width="1"/>
|
||||
<!-- Large keyhole, centered lower -->
|
||||
<circle cx="51" cy="64" r="7" fill="#1E3A5F"/>
|
||||
<path d="M48 70 L51 78 L54 70 Z" fill="#1E3A5F"/>
|
||||
<!-- Gold inner of keyhole -->
|
||||
<circle cx="51" cy="64" r="3" fill="#BFA068"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script type="text/babel" data-presets="env,react">
|
||||
const LogoRow = ({tplId, title, sub, scenarios}) => {
|
||||
const [html, setHtml] = React.useState('');
|
||||
React.useEffect(() => {
|
||||
const t = document.getElementById(tplId);
|
||||
if (t) setHtml(t.innerHTML);
|
||||
}, [tplId]);
|
||||
const Mark = ({size, dark}) => (
|
||||
<span style={{display:'inline-block',width:size,height:size,filter:dark?'invert(1) hue-rotate(180deg) brightness(1.4)':'none'}}
|
||||
dangerouslySetInnerHTML={{__html: html}}/>
|
||||
);
|
||||
return (
|
||||
<div className="ab">
|
||||
<div className="label-tag">{tplId.toUpperCase()}</div>
|
||||
<div className="name">{title}<span>{sub}</span></div>
|
||||
<div className="row">
|
||||
<div className="marks">
|
||||
<Mark size={84}/><Mark size={40}/><Mark size={20}/>
|
||||
</div>
|
||||
<div className="lock">
|
||||
<Mark size={36}/>
|
||||
<span className="wm">KEYSAT</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="scenarios">
|
||||
<div className="browser-tab"><Mark size={14}/>keysat.com — Bitcoin licensing</div>
|
||||
<div className="dark"><Mark size={18} dark={true}/>KEYSAT</div>
|
||||
<div className="chip"><Mark size={14}/>Settings</div>
|
||||
</div>
|
||||
<div className="pros">{scenarios}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const App = () => (
|
||||
<DesignCanvas title="Keysat — Logo directions, take 2" subtitle="Reframed around what Keysat does: issuing signed certificates for software paid in Bitcoin. Documents, seals, receipts, signed mail.">
|
||||
<DCSection id="docs" title="Document & certificate metaphors">
|
||||
<DCArtboard id="scroll" label="1 · Scroll with key" width={920} height={380}>
|
||||
<LogoRow tplId="logo-1" title="The Scroll" sub="Unfurled scroll with a small key beneath the lines"
|
||||
scenarios={<><strong>Vibe:</strong> ancient deed, signed grant. Maps directly to 'license issued by you.' The key is a quiet detail, not the main feature. Risk: scrolls can feel a bit fantasy-novel.</>}/>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="cert" label="2 · Certificate with wax seal" width={920} height={380}>
|
||||
<LogoRow tplId="logo-2" title="The Certificate" sub="Document with a wax seal in the corner containing a key"
|
||||
scenarios={<><strong>Vibe:</strong> diploma, deed, notarized agreement. Most literal match to 'certificate of license' (the existing visual motif on hero & detail pages). Pairs perfectly with cream-paper-gold.</>}/>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="receipt" label="4 · Receipt / ticket stub" width={920} height={380}>
|
||||
<LogoRow tplId="logo-4" title="The Receipt" sub="Ticket-stub receipt with perforation, header bar, and key"
|
||||
scenarios={<><strong>Vibe:</strong> proof of purchase, ticket, paid receipt. Connects to 'paid in Bitcoin → license issued.' The zigzag edge gives it character and prints well. Distinctive.</>}/>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="envelope" label="7 · Sealed envelope" width={920} height={380}>
|
||||
<LogoRow tplId="logo-7" title="The Sealed Letter" sub="Envelope with wax seal on the flap, key inside the seal"
|
||||
scenarios={<><strong>Vibe:</strong> formal correspondence, sealed delivery. The license arrives. Friendly, less austere than scroll/certificate. Risk: 'envelope = email' read.</>}/>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="folded" label="8 · Document with keyhole" width={920} height={380}>
|
||||
<LogoRow tplId="logo-8" title="The Keyhole Document" sub="Letter with folded corner, large keyhole shape on the page"
|
||||
scenarios={<><strong>Vibe:</strong> the document IS the lock. Quiet, modern, less ornate. Reads cleanly at small sizes (the keyhole holds up). Conceptually elegant: you license = you have the key to read.</>}/>
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
<DCSection id="marks" title="Mark / stamp metaphors">
|
||||
<DCArtboard id="window" label="3 · App window with key" width={920} height={380}>
|
||||
<LogoRow tplId="logo-3" title="The Window" sub="Application window framing a key"
|
||||
scenarios={<><strong>Vibe:</strong> 'software you license.' Most explicit product reference of all eight. Title-bar dots add a subtle Mac/desktop reading. Risk: looks more like a 'software' icon than a 'licensing' icon.</>}/>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="stamp" label="5 · Notary stamp" width={920} height={380}>
|
||||
<LogoRow tplId="logo-5" title="The Stamp" sub="Round notary mark, 'LICENSED', key, and 'ED25519'"
|
||||
scenarios={<><strong>Vibe:</strong> official stamp, certified, notary. Type baked into the mark. Distinctive and confident. Risk: relies on legible micro-text — only works above ~32px. Needs a simplified small-size variant.</>}/>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="brackets" label="6 · Code brackets + key" width={920} height={380}>
|
||||
<LogoRow tplId="logo-6" title="The Bracketed Key" sub="Angle brackets < > framing a key"
|
||||
scenarios={<><strong>Vibe:</strong> 'license, in code.' Direct nod to developer audience. Modern, technical, unfussy. Pairs nicely with the dev-focused dashboard and SDK docs. Risk: less warm than the document directions.</>}/>
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
</DesignCanvas>
|
||||
);
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,256 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Keysat — Logo directions</title>
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { margin: 0; font-family: 'Manrope', sans-serif; background: var(--cream-100); }
|
||||
.ab { background: #FBF9F2; padding: 36px 40px; box-sizing: border-box; min-height: 100%; position: relative; background-image: radial-gradient(rgba(14,31,51,0.022) 1px, transparent 1px), radial-gradient(rgba(138,111,61,0.020) 1px, transparent 1px); background-size: 3px 3px, 7px 7px; }
|
||||
.label-tag { position: absolute; top: 14px; right: 18px; font-family: 'JetBrains Mono', monospace; font-size: 9.5px; color: #8A6F3D; letter-spacing: 0.18em; text-transform: uppercase; }
|
||||
.name { font-family: 'Manrope', sans-serif; font-weight: 600; font-size: 13px; letter-spacing: -0.01em; color: #0E1F33; margin-bottom: 24px; }
|
||||
.name span { font-weight: 400; color: #8A6F3D; margin-left: 8px; font-size: 11px; letter-spacing: 0.04em; }
|
||||
.grid { display: grid; grid-template-columns: 1fr; gap: 24px; }
|
||||
.row { display: flex; align-items: center; gap: 32px; padding: 22px; background: rgba(245,241,232,0.55); border: 1px solid rgba(14,31,51,0.08); border-radius: 8px; }
|
||||
.row .marks { display: flex; align-items: center; gap: 18px; }
|
||||
.row .marks svg.lg { width: 84px; height: 84px; }
|
||||
.row .marks svg.md { width: 40px; height: 40px; }
|
||||
.row .marks svg.sm { width: 20px; height: 20px; }
|
||||
.row .lock { display: flex; align-items: center; gap: 12px; padding-left: 24px; border-left: 1px solid rgba(14,31,51,0.12); }
|
||||
.row .lock svg { width: 36px; height: 36px; }
|
||||
.row .lock .wm { font-family: 'Manrope', sans-serif; font-weight: 500; font-size: 22px; letter-spacing: 0.32em; color: #1E3A5F; text-transform: uppercase; }
|
||||
.scenarios { margin-top: 16px; display: flex; gap: 18px; align-items: center; flex-wrap: wrap; }
|
||||
.scenarios .chip { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: #FBF9F2; border: 1px solid rgba(14,31,51,0.12); border-radius: 999px; font-size: 11.5px; color: #2C3E54; }
|
||||
.scenarios .chip svg { width: 14px; height: 14px; }
|
||||
.scenarios .browser-tab { display: flex; align-items: center; gap: 6px; padding: 6px 12px; background: rgba(14,31,51,0.06); border-radius: 6px 6px 0 0; font-size: 11px; color: #2C3E54; }
|
||||
.scenarios .browser-tab svg { width: 14px; height: 14px; }
|
||||
.scenarios .dark { background: #0E1F33; padding: 8px 14px; border-radius: 6px; display: flex; align-items: center; gap: 8px; color: #FBF9F2; font-family: 'Manrope'; font-weight: 500; font-size: 12px; letter-spacing: 0.18em; text-transform: uppercase; }
|
||||
.pros { margin-top: 16px; font-size: 12px; color: #5A6B7F; line-height: 1.6; padding-top: 12px; border-top: 1px dashed rgba(14,31,51,0.15); }
|
||||
.pros strong { color: #0E1F33; font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- ============== 1. BASTION / CITADEL ============== -->
|
||||
<template id="logo-1">
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Battlements / crenellations top -->
|
||||
<path d="M16 32 L16 24 L24 24 L24 30 L32 30 L32 22 L40 22 L40 30 L48 30 L48 24 L52 24 L52 30 L60 30 L60 22 L68 22 L68 30 L76 30 L76 24 L84 24 L84 32 Z" fill="#1E3A5F"/>
|
||||
<!-- Main keep body -->
|
||||
<rect x="16" y="32" width="68" height="50" fill="#1E3A5F"/>
|
||||
<!-- Gate arch -->
|
||||
<path d="M42 82 L42 64 Q42 56 50 56 Q58 56 58 64 L58 82 Z" fill="#FBF9F2"/>
|
||||
<!-- Two arrow slits -->
|
||||
<rect x="26" y="44" width="3" height="12" fill="#FBF9F2"/>
|
||||
<rect x="71" y="44" width="3" height="12" fill="#FBF9F2"/>
|
||||
<!-- Gold cross-key behind, peeking from gate top -->
|
||||
<circle cx="50" cy="48" r="5" fill="none" stroke="#BFA068" stroke-width="2"/>
|
||||
<rect x="48.5" y="50" width="3" height="9" fill="#BFA068"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<!-- ============== 2. WAX SEAL / SIGNET ============== -->
|
||||
<template id="logo-2">
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Outer ribbon edge (wax seal scallop) -->
|
||||
<g fill="#1E3A5F">
|
||||
<circle cx="50" cy="50" r="38"/>
|
||||
</g>
|
||||
<!-- Scalloped notches around -->
|
||||
<g fill="#FBF9F2">
|
||||
<circle cx="50" cy="10" r="2"/>
|
||||
<circle cx="78" cy="22" r="2"/>
|
||||
<circle cx="90" cy="50" r="2"/>
|
||||
<circle cx="78" cy="78" r="2"/>
|
||||
<circle cx="50" cy="90" r="2"/>
|
||||
<circle cx="22" cy="78" r="2"/>
|
||||
<circle cx="10" cy="50" r="2"/>
|
||||
<circle cx="22" cy="22" r="2"/>
|
||||
</g>
|
||||
<!-- Inner gold ring -->
|
||||
<circle cx="50" cy="50" r="32" fill="none" stroke="#BFA068" stroke-width="1"/>
|
||||
<!-- K monogram, slab -->
|
||||
<g fill="#FBF9F2">
|
||||
<rect x="36" y="32" width="6" height="36"/>
|
||||
<path d="M42 50 L60 32 L66 32 L48 50 L66 68 L60 68 L42 50 Z"/>
|
||||
</g>
|
||||
<!-- Tiny bullet stars/dots above and below -->
|
||||
<circle cx="50" cy="22" r="1.5" fill="#BFA068"/>
|
||||
<circle cx="50" cy="78" r="1.5" fill="#BFA068"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<!-- ============== 3. VAULT DOOR ============== -->
|
||||
<template id="logo-3">
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Outer vault frame square -->
|
||||
<rect x="10" y="10" width="80" height="80" rx="4" fill="#1E3A5F"/>
|
||||
<!-- Inner door circle -->
|
||||
<circle cx="50" cy="50" r="30" fill="#FBF9F2"/>
|
||||
<circle cx="50" cy="50" r="30" fill="none" stroke="#BFA068" stroke-width="1"/>
|
||||
<!-- Bolts at 4 corners -->
|
||||
<circle cx="20" cy="20" r="2.5" fill="#BFA068"/>
|
||||
<circle cx="80" cy="20" r="2.5" fill="#BFA068"/>
|
||||
<circle cx="20" cy="80" r="2.5" fill="#BFA068"/>
|
||||
<circle cx="80" cy="80" r="2.5" fill="#BFA068"/>
|
||||
<!-- Spokes (8 of them) -->
|
||||
<g stroke="#1E3A5F" stroke-width="3" stroke-linecap="round">
|
||||
<line x1="50" y1="26" x2="50" y2="36"/>
|
||||
<line x1="50" y1="64" x2="50" y2="74"/>
|
||||
<line x1="26" y1="50" x2="36" y2="50"/>
|
||||
<line x1="64" y1="50" x2="74" y2="50"/>
|
||||
<line x1="33" y1="33" x2="40" y2="40"/>
|
||||
<line x1="60" y1="60" x2="67" y2="67"/>
|
||||
<line x1="67" y1="33" x2="60" y2="40"/>
|
||||
<line x1="33" y1="67" x2="40" y2="60"/>
|
||||
</g>
|
||||
<!-- Center hub -->
|
||||
<circle cx="50" cy="50" r="5" fill="#1E3A5F"/>
|
||||
<circle cx="50" cy="50" r="2" fill="#BFA068"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<!-- ============== 4. SHIELD WITH KEY TEETH ============== -->
|
||||
<template id="logo-4">
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Heater shield outline -->
|
||||
<path d="M20 18 L80 18 L80 50 Q80 76 50 86 Q20 76 20 50 Z" fill="#1E3A5F"/>
|
||||
<!-- Key teeth notched out of bottom edge -->
|
||||
<path d="M38 80 L38 84 L42 84 L42 80 L46 80 L46 84 L50 84 L50 78 Q50 84 46 86 Q42 87 38 86 Z" fill="#FBF9F2"/>
|
||||
<!-- Shield bezel inset -->
|
||||
<path d="M26 24 L74 24 L74 50 Q74 72 50 81 Q26 72 26 50 Z" fill="none" stroke="#BFA068" stroke-width="1"/>
|
||||
<!-- Centered K -->
|
||||
<g fill="#FBF9F2">
|
||||
<rect x="40" y="34" width="5" height="34"/>
|
||||
<path d="M45 51 L60 34 L66 34 L51 51 L66 68 L60 68 L45 51 Z"/>
|
||||
</g>
|
||||
<!-- Gold horizontal bar (chief) at top of shield -->
|
||||
<rect x="26" y="28" width="48" height="2" fill="#BFA068"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<!-- ============== 5. KEEP / TOWER SILHOUETTE ============== -->
|
||||
<template id="logo-5">
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Base platform -->
|
||||
<rect x="14" y="84" width="72" height="6" fill="#1E3A5F"/>
|
||||
<!-- Two flanking turrets -->
|
||||
<rect x="18" y="38" width="14" height="46" fill="#1E3A5F"/>
|
||||
<rect x="68" y="38" width="14" height="46" fill="#1E3A5F"/>
|
||||
<!-- Crenellations on turrets -->
|
||||
<path d="M18 38 L18 32 L22 32 L22 36 L26 36 L26 30 L30 30 L30 36 L32 36 L32 38 Z" fill="#1E3A5F"/>
|
||||
<path d="M68 38 L68 36 L70 36 L70 30 L74 30 L74 36 L78 36 L78 32 L82 32 L82 38 Z" fill="#1E3A5F"/>
|
||||
<!-- Central main keep (taller) -->
|
||||
<rect x="34" y="22" width="32" height="62" fill="#1E3A5F"/>
|
||||
<!-- Crenellations on main keep -->
|
||||
<path d="M34 22 L34 16 L40 16 L40 20 L46 20 L46 14 L54 14 L54 20 L60 20 L60 16 L66 16 L66 22 Z" fill="#1E3A5F"/>
|
||||
<!-- Gate -->
|
||||
<path d="M44 84 L44 70 Q44 64 50 64 Q56 64 56 70 L56 84 Z" fill="#FBF9F2"/>
|
||||
<!-- Arrow slits center keep -->
|
||||
<rect x="40" y="32" width="2" height="8" fill="#FBF9F2"/>
|
||||
<rect x="58" y="32" width="2" height="8" fill="#FBF9F2"/>
|
||||
<rect x="49" y="46" width="2" height="8" fill="#FBF9F2"/>
|
||||
<!-- Gold flag on top of central keep -->
|
||||
<rect x="49.5" y="6" width="1" height="10" fill="#BFA068"/>
|
||||
<path d="M50.5 7 L58 9 L50.5 11 Z" fill="#BFA068"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<!-- ============== 6. K-MONOGRAM AS BASTION ============== -->
|
||||
<template id="logo-6">
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- K vertical stem with crenellated top -->
|
||||
<path d="M22 86 L22 28 L26 28 L26 22 L30 22 L30 28 L34 28 L34 22 L38 22 L38 86 Z" fill="#1E3A5F"/>
|
||||
<!-- K upper diagonal with crenellated top -->
|
||||
<path d="M38 56 L60 28 L64 22 L70 22 L70 28 L74 28 L74 22 L78 22 L78 30 L52 56 Z" fill="#1E3A5F"/>
|
||||
<!-- K lower diagonal -->
|
||||
<path d="M38 56 L52 56 L78 86 L66 86 Z" fill="#1E3A5F"/>
|
||||
<!-- Gold horizontal foundation line -->
|
||||
<rect x="20" y="84" width="60" height="2" fill="#BFA068"/>
|
||||
<!-- Small dot/crest -->
|
||||
<circle cx="30" cy="14" r="2" fill="#BFA068"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script type="text/babel" data-presets="env,react">
|
||||
const LogoRow = ({tplId, title, sub, scenarios}) => {
|
||||
const [html, setHtml] = React.useState('');
|
||||
React.useEffect(() => {
|
||||
const t = document.getElementById(tplId);
|
||||
if (t) setHtml(t.innerHTML);
|
||||
}, [tplId]);
|
||||
return (
|
||||
<div className="ab">
|
||||
<div className="label-tag">{tplId.toUpperCase()}</div>
|
||||
<div className="name">{title}<span>{sub}</span></div>
|
||||
<div className="row">
|
||||
<div className="marks">
|
||||
<span className="lg" dangerouslySetInnerHTML={{__html: html}} style={{display:'inline-block',width:84,height:84}}/>
|
||||
<span className="md" dangerouslySetInnerHTML={{__html: html}} style={{display:'inline-block',width:40,height:40}}/>
|
||||
<span className="sm" dangerouslySetInnerHTML={{__html: html}} style={{display:'inline-block',width:20,height:20}}/>
|
||||
</div>
|
||||
<div className="lock">
|
||||
<span dangerouslySetInnerHTML={{__html: html}} style={{display:'inline-block',width:36,height:36}}/>
|
||||
<span className="wm">KEYSAT</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="scenarios">
|
||||
<div className="browser-tab">
|
||||
<span dangerouslySetInnerHTML={{__html: html}} style={{display:'inline-block',width:14,height:14}}/>
|
||||
keysat.com — Bitcoin licensing
|
||||
</div>
|
||||
<div className="dark">
|
||||
<span dangerouslySetInnerHTML={{__html: html}} style={{display:'inline-block',width:18,height:18,filter:'invert(1) hue-rotate(180deg) brightness(1.5)'}}/>
|
||||
KEYSAT
|
||||
</div>
|
||||
<div className="chip">
|
||||
<span dangerouslySetInnerHTML={{__html: html}} style={{display:'inline-block',width:14,height:14}}/>
|
||||
Settings
|
||||
</div>
|
||||
</div>
|
||||
<div className="pros">{scenarios}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const App = () => (
|
||||
<DesignCanvas title="Keysat — Logo directions" subtitle="Six fortress / protection metaphors. Each shows the mark at three sizes, in a horizontal lockup, on a tab favicon, on a dark UI badge, and as a settings chip — so you can judge it small.">
|
||||
<DCSection id="logos" title="Logo metaphors — fortress, vault, signet, keep">
|
||||
<DCArtboard id="bastion" label="1 · Bastion / citadel" width={920} height={400}>
|
||||
<LogoRow tplId="logo-1" title="Bastion" sub="Crenellated keep with gate + small gold key behind"
|
||||
scenarios={<><strong>Vibe:</strong> sovereign land, fortified perimeter, castle doctrine. Reads instantly as 'protection.' Key is integrated. Risk: a touch literal / medieval.</>}/>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="seal" label="2 · Wax seal / signet" width={920} height={400}>
|
||||
<LogoRow tplId="logo-2" title="Signet seal" sub="Round wax seal with K monogram"
|
||||
scenarios={<><strong>Vibe:</strong> notary, royal decree, signed document. Strong narrative tie to the Ed25519 / 'signed certificate' product. Reads as authenticity rather than fortress — softer 'protection.'</>}/>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="vault" label="3 · Vault door" width={920} height={400}>
|
||||
<LogoRow tplId="logo-3" title="Vault door" sub="Bank-vault door with bolts and spokes"
|
||||
scenarios={<><strong>Vibe:</strong> hard security, hardened storage, Swiss bank. Most explicitly says 'fortress' of the six. Risk: very fintech / neobank cliché — many crypto products use this.</>}/>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="shield" label="4 · Shield + key teeth" width={920} height={400}>
|
||||
<LogoRow tplId="logo-4" title="Shield" sub="Heraldic shield with key-teeth notched bottom + K monogram"
|
||||
scenarios={<><strong>Vibe:</strong> coat-of-arms, chivalric protection, K monogram inside. The teeth-as-notches detail is subtle and specific to Keysat. Strong fortress read.</>}/>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="keep" label="5 · Keep / tower silhouette" width={920} height={400}>
|
||||
<LogoRow tplId="logo-5" title="The Keep" sub="Three-tower medieval keep with central flag"
|
||||
scenarios={<><strong>Vibe:</strong> Tower of London, stronghold, quiet authority. Most literal 'fortress.' Cleaner than direction 1 because of vertical emphasis. Pairs beautifully with 'archival deed' visual story.</>}/>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="kmono" label="6 · K-monogram as bastion" width={920} height={400}>
|
||||
<LogoRow tplId="logo-6" title="K-bastion" sub="Letterform K with crenellated tops"
|
||||
scenarios={<><strong>Vibe:</strong> wordmark and mark merged. Most distinctive — only Keysat could use this. Reads as 'K' and 'fortress wall' simultaneously. Risk: looks more like clever lettering than a literal protective symbol.</>}/>
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
</DesignCanvas>
|
||||
);
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,173 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Keysat — Type Exploration</title>
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,600;9..144,800&family=Cormorant+Garamond:wght@500;700&family=Spectral:wght@500;700&family=Manrope:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600;700&family=Roboto+Mono:wght@400;500;600&family=Newsreader:opsz,wght@6..72,400;6..72,600;6..72,700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { margin: 0; font-family: var(--font-body); background: var(--cream-100); }
|
||||
|
||||
.ab { background: #FBF9F2; padding: 48px 56px; min-height: 100%; box-sizing: border-box; position: relative; background-image: radial-gradient(rgba(14,31,51,0.022) 1px, transparent 1px), radial-gradient(rgba(138,111,61,0.020) 1px, transparent 1px); background-size: 3px 3px, 7px 7px; }
|
||||
.ab .row-mark { display: flex; align-items: center; gap: 16px; margin-bottom: 36px; padding-bottom: 24px; border-bottom: 1px solid rgba(14,31,51,0.12); }
|
||||
.ab .row-mark img { width: 56px; height: 56px; }
|
||||
.ab .eyebrow { font-size: 11px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; color: #8A6F3D; margin-bottom: 18px; display: inline-flex; align-items: center; gap: 10px; }
|
||||
.ab .eyebrow::before { content: ''; width: 24px; height: 1px; background: #BFA068; }
|
||||
.ab .lede { font-size: 17px; line-height: 1.55; color: #2C3E54; max-width: 520px; margin: 18px 0 28px; }
|
||||
.ab .cta { display: inline-flex; align-items: center; gap: 8px; padding: 11px 20px; background: #1E3A5F; color: #FBF9F2; border-radius: 8px; font-weight: 600; font-size: 14px; }
|
||||
.ab .cert { margin-top: 32px; background: #FBF9F2; border: 1px solid rgba(14,31,51,0.12); border-radius: 12px; box-shadow: 0 0 0 1px #BFA068 inset, 0 2px 4px rgba(14,31,51,0.06); padding: 22px 24px; max-width: 460px; }
|
||||
.ab .cert .stamp { font-size: 9.5px; font-weight: 700; letter-spacing: 0.22em; text-transform: uppercase; color: #8A6F3D; margin-bottom: 10px; }
|
||||
.ab .cert .field { font-size: 10.5px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: #5A6B7F; margin-bottom: 3px; }
|
||||
.ab .cert .value { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 14px; color: #0E1F33; margin-bottom: 12px; }
|
||||
|
||||
/* ============== Direction A — Editorial serif ============== */
|
||||
.ab.a .wordmark { font-family: 'Fraunces', serif; font-weight: 700; font-size: 36px; letter-spacing: 0.12em; color: #1E3A5F; line-height: 1; font-variation-settings: 'opsz' 144; }
|
||||
.ab.a .wordmark .small { font-size: 11px; font-weight: 500; letter-spacing: 0.22em; color: #8A6F3D; display: block; margin-top: 6px; }
|
||||
.ab.a h1 { font-family: 'Fraunces', serif; font-weight: 600; font-size: 56px; line-height: 1.05; color: #0E1F33; margin: 0 0 4px; letter-spacing: -0.015em; font-variation-settings: 'opsz' 144; }
|
||||
.ab.a h1 em { font-style: italic; font-weight: 600; color: #1E3A5F; }
|
||||
.ab.a .cert h4 { font-family: 'Fraunces', serif; font-weight: 600; font-size: 22px; color: #0E1F33; margin: 0 0 4px; letter-spacing: -0.005em; }
|
||||
|
||||
/* ============== Direction B — Restrained classical sans ============== */
|
||||
.ab.b .wordmark { font-family: 'Manrope', sans-serif; font-weight: 500; font-size: 28px; letter-spacing: 0.32em; color: #1E3A5F; line-height: 1; text-transform: uppercase; }
|
||||
.ab.b .wordmark .small { font-size: 10.5px; font-weight: 500; letter-spacing: 0.22em; color: #8A6F3D; display: block; margin-top: 8px; text-transform: uppercase; }
|
||||
.ab.b h1 { font-family: 'Manrope', sans-serif; font-weight: 500; font-size: 44px; line-height: 1.1; color: #0E1F33; margin: 0; letter-spacing: -0.022em; }
|
||||
.ab.b h1 strong { font-weight: 700; color: #1E3A5F; }
|
||||
.ab.b .cert h4 { font-family: 'Manrope', sans-serif; font-weight: 600; font-size: 19px; color: #0E1F33; margin: 0 0 4px; letter-spacing: -0.01em; }
|
||||
|
||||
/* ============== Direction C — Slab / typewriter ============== */
|
||||
.ab.c .wordmark { font-family: 'Roboto Mono', monospace; font-weight: 600; font-size: 28px; letter-spacing: 0.04em; color: #1E3A5F; line-height: 1; }
|
||||
.ab.c .wordmark .small { font-family: 'IBM Plex Sans', sans-serif; font-size: 11px; font-weight: 500; letter-spacing: 0.18em; color: #8A6F3D; display: block; margin-top: 8px; text-transform: uppercase; }
|
||||
.ab.c h1 { font-family: 'Newsreader', serif; font-weight: 600; font-size: 48px; line-height: 1.1; color: #0E1F33; margin: 0; letter-spacing: -0.015em; }
|
||||
.ab.c h1 .mono { font-family: 'Roboto Mono', monospace; font-size: 0.78em; font-weight: 500; color: #1E3A5F; padding: 0 6px; background: rgba(191,160,104,0.18); border-radius: 4px; }
|
||||
.ab.c .cert h4 { font-family: 'Newsreader', serif; font-weight: 600; font-size: 22px; color: #0E1F33; margin: 0 0 4px; }
|
||||
|
||||
/* ============== Direction D — Mono-forward / cypherpunk-quiet ============== */
|
||||
.ab.d .wordmark { font-family: 'Roboto Mono', monospace; font-weight: 500; font-size: 24px; letter-spacing: 0.02em; color: #1E3A5F; line-height: 1; }
|
||||
.ab.d .wordmark .symbol { color: #8A6F3D; font-weight: 500; }
|
||||
.ab.d .wordmark .small { font-family: 'IBM Plex Sans', sans-serif; font-size: 10.5px; font-weight: 500; letter-spacing: 0.22em; color: #8A6F3D; display: block; margin-top: 8px; text-transform: uppercase; }
|
||||
.ab.d h1 { font-family: 'IBM Plex Sans', sans-serif; font-weight: 600; font-size: 40px; line-height: 1.15; color: #0E1F33; margin: 0; letter-spacing: -0.02em; }
|
||||
.ab.d h1 .mono { font-family: 'Roboto Mono', monospace; font-weight: 500; font-size: 0.92em; color: #1E3A5F; }
|
||||
.ab.d .cert h4 { font-family: 'IBM Plex Sans', sans-serif; font-weight: 600; font-size: 18px; color: #0E1F33; margin: 0 0 4px; letter-spacing: -0.01em; }
|
||||
.ab.d .lede { font-family: 'IBM Plex Sans', sans-serif; }
|
||||
|
||||
.label-tag { position: absolute; top: 16px; right: 20px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: #8A6F3D; letter-spacing: 0.18em; text-transform: uppercase; }
|
||||
.pros { margin-top: 22px; font-size: 12px; color: #5A6B7F; line-height: 1.6; max-width: 480px; padding-top: 14px; border-top: 1px dashed rgba(14,31,51,0.15); }
|
||||
.pros strong { color: #0E1F33; font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||
|
||||
<div id="root"></div>
|
||||
|
||||
<template id="art-a">
|
||||
<div class="ab a">
|
||||
<div class="label-tag">A · Editorial serif</div>
|
||||
<div class="row-mark">
|
||||
<img src="../assets/keysat-mark-v2.svg" alt="">
|
||||
<div class="wordmark">Keysat<span class="small">— Software licensing for Bitcoin creators —</span></div>
|
||||
</div>
|
||||
<div class="eyebrow">For independent creators</div>
|
||||
<h1>Sell software. <em>Keep the keys.</em></h1>
|
||||
<p class="lede">A self-hosted licensing server for indie software, paid in Bitcoin. The signing key, the customer list, the payment rails — all yours.</p>
|
||||
<a class="cta">Install Keysat →</a>
|
||||
<div class="cert">
|
||||
<div class="stamp">— Certificate of License —</div>
|
||||
<h4>Sundial 2.0</h4>
|
||||
<div class="field" style="margin-top: 10px">License key</div>
|
||||
<div class="value">KS-9F2A-7C41-XK22-6D8E</div>
|
||||
</div>
|
||||
<div class="pros"><strong>Vibe:</strong> rare-book, archival deed, classical authority. Most aligned with cream/paper/gold story. Quiet and serious. Best fit if Keysat is meant to feel <em>old-school trustworthy</em>.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="art-b">
|
||||
<div class="ab b">
|
||||
<div class="label-tag">B · Restrained classical sans</div>
|
||||
<div class="row-mark">
|
||||
<img src="../assets/keysat-mark-v2.svg" alt="">
|
||||
<div class="wordmark">KEYSAT<span class="small">Bitcoin licensing</span></div>
|
||||
</div>
|
||||
<div class="eyebrow">For independent creators</div>
|
||||
<h1>Bitcoin-paid software licensing, <strong>self-hosted on Start9.</strong></h1>
|
||||
<p class="lede">Buyers pay in Bitcoin via your own BTCPay. Your software verifies signed keys offline. No SaaS, no middleman, no platform risk.</p>
|
||||
<a class="cta">Install Keysat →</a>
|
||||
<div class="cert">
|
||||
<div class="stamp">— Certificate of License —</div>
|
||||
<h4>Sundial 2.0</h4>
|
||||
<div class="field" style="margin-top: 10px">License key</div>
|
||||
<div class="value">KS-9F2A-7C41-XK22-6D8E</div>
|
||||
</div>
|
||||
<div class="pros"><strong>Vibe:</strong> Lloyd's, Apollo, "engineered." Geometric but humanist — not chunky. Reads as a serious indie tool company. Like the current direction but <em>much</em> lighter weight. Probably the safest choice.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="art-c">
|
||||
<div class="ab c">
|
||||
<div class="label-tag">C · Serif + mono hybrid</div>
|
||||
<div class="row-mark">
|
||||
<img src="../assets/keysat-mark-v2.svg" alt="">
|
||||
<div class="wordmark">keysat<span class="small">Bitcoin software licensing</span></div>
|
||||
</div>
|
||||
<div class="eyebrow">For independent creators</div>
|
||||
<h1>Sell software. <span class="mono">get paid in BTC.</span> Keep the signing key.</h1>
|
||||
<p class="lede">A self-hosted licensing server for indie creators. Runs on your own Start9. BTCPay handles payment, your hardware holds the keys.</p>
|
||||
<a class="cta">Install Keysat →</a>
|
||||
<div class="cert">
|
||||
<div class="stamp">— Certificate of License —</div>
|
||||
<h4>Sundial 2.0</h4>
|
||||
<div class="field" style="margin-top: 10px">License key</div>
|
||||
<div class="value">KS-9F2A-7C41-XK22-6D8E</div>
|
||||
</div>
|
||||
<div class="pros"><strong>Vibe:</strong> indie-hacker print shop, Carpenter / Ledger / Hacker News classy. Editorial serif with monospace technical injections. Distinctive, but more eccentric.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="art-d">
|
||||
<div class="ab d">
|
||||
<div class="label-tag">D · Mono-forward / cypherpunk-quiet</div>
|
||||
<div class="row-mark">
|
||||
<img src="../assets/keysat-mark-v2.svg" alt="">
|
||||
<div class="wordmark"><span class="symbol">$</span> keysat<span class="small">Bitcoin software licensing</span></div>
|
||||
</div>
|
||||
<div class="eyebrow">For independent creators</div>
|
||||
<h1>Software licensing for Bitcoin creators — <span class="mono">self-hosted</span>.</h1>
|
||||
<p class="lede">Buyers pay in Bitcoin via your own BTCPay. Your software verifies signed keys offline. You own the signing key, the customer list, the payment rails.</p>
|
||||
<a class="cta">Install Keysat →</a>
|
||||
<div class="cert">
|
||||
<div class="stamp">— Certificate of License —</div>
|
||||
<h4>Sundial 2.0</h4>
|
||||
<div class="field" style="margin-top: 10px">License key</div>
|
||||
<div class="value">KS-9F2A-7C41-XK22-6D8E</div>
|
||||
</div>
|
||||
<div class="pros"><strong>Vibe:</strong> cypherpunk-quiet, BTCPay-native, terminal-flavored. Body in IBM Plex Sans. Closer to the audience's actual taste, but loses some of the cream-paper classical-ness.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script type="text/babel" data-presets="env,react">
|
||||
const Art = ({tplId}) => {
|
||||
const ref = React.useRef(null);
|
||||
React.useEffect(() => {
|
||||
const tpl = document.getElementById(tplId);
|
||||
if (tpl && ref.current) ref.current.innerHTML = tpl.innerHTML;
|
||||
}, [tplId]);
|
||||
return <div ref={ref} style={{width:'100%',height:'100%'}}/>;
|
||||
};
|
||||
|
||||
const App = () => (
|
||||
<DesignCanvas title="Keysat — Type & Wordmark Directions" subtitle="Same hero content, four type systems. Pick a direction (or mix) and I'll lock it in across the system.">
|
||||
<DCSection id="hero" title="Hero typography on cream paper">
|
||||
<DCArtboard id="a" label="A · Editorial serif (Fraunces)" width={760} height={780}><Art tplId="art-a"/></DCArtboard>
|
||||
<DCArtboard id="b" label="B · Classical sans (Manrope, lighter)" width={760} height={780}><Art tplId="art-b"/></DCArtboard>
|
||||
<DCArtboard id="c" label="C · Serif + mono hybrid (Newsreader + Roboto Mono)" width={760} height={780}><Art tplId="art-c"/></DCArtboard>
|
||||
<DCArtboard id="d" label="D · Mono-forward (IBM Plex + Roboto Mono)" width={760} height={780}><Art tplId="art-d"/></DCArtboard>
|
||||
</DCSection>
|
||||
</DesignCanvas>
|
||||
);
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,42 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Keysat — Badges & Tags</title>
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body { margin: 0; padding: 22px; font-family: var(--font-body); background: var(--bg-page); color: var(--ink-900); }
|
||||
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 12px; }
|
||||
.label { font-family: var(--font-mono); font-size: 11px; color: var(--ink-500); min-width: 78px; }
|
||||
.badge { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 600; padding: 3px 9px; border-radius: 999px; line-height: 1.4; border: 1px solid transparent; }
|
||||
.b-success { background: #E3F0EA; color: #205c47; border-color: rgba(45,122,95,0.25); }
|
||||
.b-warning { background: #F7EFD7; color: #7a5814; border-color: rgba(184,134,31,0.3); }
|
||||
.b-danger { background: #F4E0E0; color: #8a2828; border-color: rgba(178,58,58,0.25); }
|
||||
.b-info { background: #E4EAF1; color: #1E3A5F; border-color: rgba(30,58,95,0.20); }
|
||||
.b-neutral { background: #EDE7D7; color: #2C3E54; border-color: rgba(14,31,51,0.10); }
|
||||
.b-gold { background: transparent; color: #8A6F3D; border-color: #BFA068; }
|
||||
.dot { width: 6px; height: 6px; border-radius: 50%; }
|
||||
.pillet { font-size: 11.5px; padding: 4px 10px; border-radius: 999px; background: #FBF9F2; border: 1px solid rgba(14,31,51,0.12); color: var(--ink-700); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="row"><span class="label">status</span>
|
||||
<span class="badge b-success"><span class="dot" style="background:#2D7A5F"></span>Active</span>
|
||||
<span class="badge b-warning"><span class="dot" style="background:#B8861F"></span>Trial</span>
|
||||
<span class="badge b-danger"><span class="dot" style="background:#B23A3A"></span>Expired</span>
|
||||
<span class="badge b-info"><span class="dot" style="background:#2A4A75"></span>Pending</span>
|
||||
<span class="badge b-neutral">Draft</span>
|
||||
</div>
|
||||
<div class="row"><span class="label">accent</span>
|
||||
<span class="badge b-gold">★ Verified creator</span>
|
||||
<span class="badge b-gold">Lifetime</span>
|
||||
</div>
|
||||
<div class="row"><span class="label">pillets</span>
|
||||
<span class="pillet">Rust</span>
|
||||
<span class="pillet">TypeScript</span>
|
||||
<span class="pillet">Python</span>
|
||||
<span class="pillet">Go (planned)</span>
|
||||
<span class="pillet">Swift (planned)</span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Keysat — Logo Mark</title>
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: var(--font-body); background: var(--bg-page); color: var(--ink-900); display: grid; grid-template-columns: 1fr 1fr; height: 220px; }
|
||||
.paper-texture { background-image: radial-gradient(rgba(14,31,51,0.04) 1px, transparent 1.4px), radial-gradient(rgba(138,111,61,0.035) 1px, transparent 1.2px); background-size: 3px 3px, 7px 7px; background-color: #F5F1E8; }
|
||||
.cell { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; }
|
||||
.cell.dark { background: #0E1F33; color: #F5F1E8; }
|
||||
img.logo { width: 110px; height: 110px; }
|
||||
.label { font-size: 11px; letter-spacing: 0.15em; text-transform: uppercase; opacity: 0.7; font-weight: 600; }
|
||||
.wordmark { font-family: var(--font-display); font-weight: 900; font-size: 40px; letter-spacing: -0.02em; line-height: 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="cell paper-texture">
|
||||
<img src="../assets/keysat-logo-thumbnail.png" alt="Keysat logo" class="logo">
|
||||
<div class="label">Logo · cream</div>
|
||||
</div>
|
||||
<div class="cell dark">
|
||||
<div class="wordmark" style="color: #F5F1E8">KEYSAT</div>
|
||||
<div class="label">Wordmark · navy surface</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,46 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Keysat — Buttons</title>
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body { margin: 0; padding: 24px; font-family: var(--font-body); background: var(--bg-page); }
|
||||
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; }
|
||||
.label { font-family: var(--font-mono); font-size: 11px; color: var(--ink-500); min-width: 84px; }
|
||||
.btn { font-family: var(--font-body); font-weight: 600; font-size: 14px; padding: 10px 18px; border-radius: 8px; border: 1px solid transparent; cursor: pointer; transition: all 120ms; line-height: 1; }
|
||||
.btn.primary { background: #1E3A5F; color: #FBF9F2; border-color: #1E3A5F; }
|
||||
.btn.primary.hover { background: #142A47; border-color: #142A47; }
|
||||
.btn.primary.press { background: #0E1F33; border-color: #0E1F33; transform: translateY(1px); }
|
||||
.btn.secondary { background: #FBF9F2; color: #1E3A5F; border-color: rgba(14,31,51,0.20); }
|
||||
.btn.secondary.hover { background: #EDE7D7; }
|
||||
.btn.ghost { background: transparent; color: #1E3A5F; border-color: transparent; }
|
||||
.btn.ghost.hover { background: rgba(14,31,51,0.05); }
|
||||
.btn.gold { background: transparent; color: #8A6F3D; border-color: #BFA068; }
|
||||
.btn.danger { background: transparent; color: #B23A3A; border-color: rgba(178,58,58,0.4); }
|
||||
.btn.sm { font-size: 12.5px; padding: 7px 12px; border-radius: 6px; }
|
||||
.btn.lg { font-size: 16px; padding: 14px 24px; }
|
||||
.btn.icon { display: inline-flex; align-items: center; gap: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="row"><span class="label">primary</span>
|
||||
<button class="btn primary">Connect BTCPay</button>
|
||||
<button class="btn primary hover">Hover</button>
|
||||
<button class="btn primary press">Press</button>
|
||||
<button class="btn primary" disabled style="opacity:0.4;cursor:not-allowed">Disabled</button>
|
||||
</div>
|
||||
<div class="row"><span class="label">secondary</span>
|
||||
<button class="btn secondary">View docs</button>
|
||||
<button class="btn secondary hover">Hover</button>
|
||||
<button class="btn ghost">Cancel</button>
|
||||
<button class="btn ghost hover">Hover</button>
|
||||
</div>
|
||||
<div class="row"><span class="label">utility</span>
|
||||
<button class="btn gold">Verified creator</button>
|
||||
<button class="btn danger">Revoke license</button>
|
||||
<button class="btn primary sm">Sign in</button>
|
||||
<button class="btn primary lg">Install Keysat</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,45 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Keysat — Cards</title>
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body { margin: 0; padding: 22px; font-family: var(--font-body); background: var(--bg-page); color: var(--ink-900); }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; }
|
||||
.card { background: #FBF9F2; border: 1px solid rgba(14,31,51,0.12); border-radius: 12px; padding: 16px; box-shadow: 0 1px 2px rgba(14,31,51,0.06); }
|
||||
.card.featured { background: #FBF9F2; box-shadow: 0 0 0 1px #BFA068 inset, 0 2px 4px rgba(14,31,51,0.06), 0 4px 12px rgba(14,31,51,0.06); }
|
||||
.card.dark { background: #0E1F33; color: #F5F1E8; border-color: rgba(245,241,232,0.18); }
|
||||
.eyebrow { font-size: 10px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; color: #8A6F3D; margin-bottom: 6px; }
|
||||
.card.dark .eyebrow { color: #BFA068; }
|
||||
.title { font-family: var(--font-display); font-size: 17px; font-weight: 700; letter-spacing: -0.01em; margin-bottom: 6px; }
|
||||
.body { font-size: 12.5px; color: var(--ink-500); line-height: 1.5; }
|
||||
.card.dark .body { color: rgba(245,241,232,0.7); }
|
||||
.row { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; }
|
||||
.price { font-family: var(--font-display); font-weight: 800; font-size: 18px; }
|
||||
.meta-mono { font-family: var(--font-mono); font-size: 11px; color: var(--ink-400); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<div class="eyebrow">Standard</div>
|
||||
<div class="title">Sundial 2.0</div>
|
||||
<div class="body">3 active licenses, 1 trial. Default policy is 1-year, single-seat.</div>
|
||||
<div class="row"><span class="price">50,000 sats</span><span class="meta-mono">12 sold</span></div>
|
||||
</div>
|
||||
<div class="card featured">
|
||||
<div class="eyebrow">Featured · Gold stroke</div>
|
||||
<div class="title">Sundial Pro</div>
|
||||
<div class="body">Multi-seat, perpetual. Includes priority email support from the creator.</div>
|
||||
<div class="row"><span class="price">200,000 sats</span><span class="meta-mono">3 sold</span></div>
|
||||
</div>
|
||||
<div class="card dark">
|
||||
<div class="eyebrow">Dark surface</div>
|
||||
<div class="title">Sovereign by default</div>
|
||||
<div class="body">Backed up automatically by StartOS as part of your normal backup routine.</div>
|
||||
<div class="row"><span class="price" style="color:#BFA068">₿ 0.00214</span><span class="meta-mono">≈ $128.40</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Keysat — Cream & Gold</title>
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body { margin: 0; padding: 20px; font-family: var(--font-body); background: var(--bg-page); }
|
||||
.row { display: flex; gap: 0; border-radius: var(--r-md); overflow: hidden; box-shadow: var(--shadow-sm); margin-bottom: 14px; border: 1px solid var(--border-1); }
|
||||
.sw { flex: 1; padding: 14px 12px; min-height: 70px; display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.sw .name { font-size: 11px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; opacity: 0.85; }
|
||||
.sw .hex { font-family: var(--font-mono); font-size: 11px; opacity: 0.85; }
|
||||
.label { font-family: var(--font-display); font-size: 12px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; color: var(--gold-700); margin: 0 4px 6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="label">Cream — paper surfaces</div>
|
||||
<div class="row">
|
||||
<div class="sw" style="background:#FBF9F2;color:#0E1F33"><span class="name">50</span><span class="hex">#FBF9F2</span></div>
|
||||
<div class="sw" style="background:#F5F1E8;color:#0E1F33"><span class="name">100 ★</span><span class="hex">#F5F1E8</span></div>
|
||||
<div class="sw" style="background:#EDE7D7;color:#0E1F33"><span class="name">200</span><span class="hex">#EDE7D7</span></div>
|
||||
<div class="sw" style="background:#E1D8C0;color:#0E1F33"><span class="name">300</span><span class="hex">#E1D8C0</span></div>
|
||||
<div class="sw" style="background:#C9BC9A;color:#0E1F33"><span class="name">400</span><span class="hex">#C9BC9A</span></div>
|
||||
</div>
|
||||
<div class="label">Gold — accent (use sparingly)</div>
|
||||
<div class="row">
|
||||
<div class="sw" style="background:#8A6F3D;color:#FBF9F2"><span class="name">700</span><span class="hex">#8A6F3D</span></div>
|
||||
<div class="sw" style="background:#A88652;color:#FBF9F2"><span class="name">600</span><span class="hex">#A88652</span></div>
|
||||
<div class="sw" style="background:#BFA068;color:#0E1F33"><span class="name">500 ★</span><span class="hex">#BFA068</span></div>
|
||||
<div class="sw" style="background:#D4B985;color:#0E1F33"><span class="name">400</span><span class="hex">#D4B985</span></div>
|
||||
<div class="sw" style="background:#E5CFA5;color:#0E1F33"><span class="name">300</span><span class="hex">#E5CFA5</span></div>
|
||||
<div class="sw" style="background:#F0E2C5;color:#0E1F33"><span class="name">200</span><span class="hex">#F0E2C5</span></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Keysat — Color Palette (Brand)</title>
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body { margin: 0; padding: 20px; font-family: var(--font-body); background: var(--bg-page); }
|
||||
.row { display: flex; gap: 0; border-radius: var(--r-md); overflow: hidden; box-shadow: var(--shadow-sm); margin-bottom: 14px; }
|
||||
.sw { flex: 1; padding: 14px 12px; min-height: 78px; display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.sw .name { font-size: 11px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; opacity: 0.85; }
|
||||
.sw .hex { font-family: var(--font-mono); font-size: 11px; opacity: 0.85; }
|
||||
.label { font-family: var(--font-display); font-size: 12px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; color: var(--gold-700); margin: 0 4px 6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="label">Navy — primary brand</div>
|
||||
<div class="row">
|
||||
<div class="sw" style="background:#0E1F33;color:#F5F1E8"><span class="name">950</span><span class="hex">#0E1F33</span></div>
|
||||
<div class="sw" style="background:#142A47;color:#F5F1E8"><span class="name">900</span><span class="hex">#142A47</span></div>
|
||||
<div class="sw" style="background:#1E3A5F;color:#F5F1E8"><span class="name">800 ★</span><span class="hex">#1E3A5F</span></div>
|
||||
<div class="sw" style="background:#2A4A75;color:#F5F1E8"><span class="name">700</span><span class="hex">#2A4A75</span></div>
|
||||
<div class="sw" style="background:#5074A1;color:#F5F1E8"><span class="name">500</span><span class="hex">#5074A1</span></div>
|
||||
<div class="sw" style="background:#A6B7CF;color:#0E1F33"><span class="name">300</span><span class="hex">#A6B7CF</span></div>
|
||||
<div class="sw" style="background:#E4EAF1;color:#0E1F33"><span class="name">100</span><span class="hex">#E4EAF1</span></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Keysat — Semantic Colors</title>
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body { margin: 0; padding: 20px; font-family: var(--font-body); background: var(--bg-page); }
|
||||
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||
.item { border-radius: var(--r-md); overflow: hidden; border: 1px solid var(--border-1); background: white; }
|
||||
.swatch { padding: 14px; min-height: 56px; display: flex; align-items: center; gap: 10px; }
|
||||
.dot { width: 14px; height: 14px; border-radius: 50%; border: 1px solid rgba(0,0,0,0.1); }
|
||||
.name { font-size: 12px; font-weight: 700; color: var(--ink-900); }
|
||||
.meta { padding: 8px 14px 12px; font-family: var(--font-mono); font-size: 11px; color: var(--ink-500); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="grid">
|
||||
<div class="item">
|
||||
<div class="swatch" style="background:#E3F0EA"><span class="dot" style="background:#2D7A5F"></span><span class="name">Success</span></div>
|
||||
<div class="meta">#2D7A5F</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="swatch" style="background:#F7EFD7"><span class="dot" style="background:#B8861F"></span><span class="name">Warning</span></div>
|
||||
<div class="meta">#B8861F</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="swatch" style="background:#F4E0E0"><span class="dot" style="background:#B23A3A"></span><span class="name">Danger</span></div>
|
||||
<div class="meta">#B23A3A</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="swatch" style="background:#E4EAF1"><span class="dot" style="background:#2A4A75"></span><span class="name">Info</span></div>
|
||||
<div class="meta">#2A4A75</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Keysat — Form Inputs</title>
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body { margin: 0; padding: 22px 24px; font-family: var(--font-body); background: var(--bg-page); color: var(--ink-900); }
|
||||
.field { margin-bottom: 14px; }
|
||||
.label { display: block; font-size: 12.5px; font-weight: 600; color: var(--ink-700); margin-bottom: 6px; letter-spacing: 0.01em; }
|
||||
.meta { font-size: 11.5px; color: var(--ink-500); margin-top: 5px; }
|
||||
.input { width: 100%; padding: 10px 12px; font-family: var(--font-body); font-size: 14px; border: 1px solid rgba(14,31,51,0.20); border-radius: 8px; background: #FFFFFF; color: var(--ink-900); box-sizing: border-box; transition: border 120ms, box-shadow 120ms; }
|
||||
.input:focus { outline: none; border-color: #1E3A5F; box-shadow: 0 0 0 3px rgba(30,58,95,0.20); }
|
||||
.input.error { border-color: #B23A3A; box-shadow: 0 0 0 3px rgba(178,58,58,0.15); }
|
||||
.input.mono { font-family: var(--font-mono); font-size: 13px; }
|
||||
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.err { color: #B23A3A; font-size: 11.5px; margin-top: 4px; }
|
||||
.check { display: inline-flex; align-items: center; gap: 8px; font-size: 13.5px; }
|
||||
.check input { accent-color: #1E3A5F; width: 16px; height: 16px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="row">
|
||||
<div class="field"><label class="label">Product name</label><input class="input" value="Sundial 2.0"><div class="meta">Shown on receipts and the public purchase page.</div></div>
|
||||
<div class="field"><label class="label">Price</label><input class="input mono" value="50,000 sats"></div>
|
||||
</div>
|
||||
<div class="field"><label class="label">Public key (PEM)</label><input class="input mono" value="-----BEGIN PUBLIC KEY-----…"></div>
|
||||
<div class="field"><label class="label">License email</label><input class="input error" value="not-an-email"><div class="err">Enter a valid email.</div></div>
|
||||
<label class="check"><input type="checkbox" checked> Send activation email automatically</label>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Keysat — License Key Display</title>
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body { margin: 0; padding: 22px 24px; font-family: var(--font-body); background: var(--bg-page); color: var(--ink-900); }
|
||||
.stack > * + * { margin-top: 12px; }
|
||||
.label { font-size: 11px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; color: #8A6F3D; }
|
||||
.key-card { background: white; border: 1px solid rgba(14,31,51,0.12); border-radius: 10px; padding: 14px 16px; display: flex; align-items: center; justify-content: space-between; box-shadow: 0 1px 2px rgba(14,31,51,0.05); }
|
||||
.key { font-family: var(--font-mono); font-size: 16px; font-weight: 600; color: #0E1F33; letter-spacing: 0.02em; }
|
||||
.copy { font-size: 11.5px; color: var(--ink-500); border: 1px solid rgba(14,31,51,0.15); padding: 5px 10px; border-radius: 6px; background: #FBF9F2; cursor: pointer; }
|
||||
.key-card.featured { box-shadow: 0 0 0 1px #BFA068 inset, 0 2px 4px rgba(14,31,51,0.06); background: #FBF9F2; }
|
||||
.pubkey { font-family: var(--font-mono); font-size: 12px; color: var(--ink-700); background: #FBF9F2; border: 1px dashed rgba(14,31,51,0.2); padding: 10px 12px; border-radius: 8px; line-height: 1.6; word-break: break-all; }
|
||||
.small { font-size: 11px; color: var(--ink-500); margin-top: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="stack">
|
||||
<div>
|
||||
<div class="label" style="margin-bottom:6px">License key</div>
|
||||
<div class="key-card"><span class="key">KS-9F2A-7C41-XK22-6D8E</span><span class="copy">Copy</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label" style="margin-bottom:6px">Lifetime · gold stroke</div>
|
||||
<div class="key-card featured"><span class="key">KS-LIFE-2026-AURM-PR01</span><span class="copy">Copy</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label" style="margin-bottom:6px">Issuer public key</div>
|
||||
<div class="pubkey">mz7q8r4t1v…h3k2pXq9wL · Ed25519</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Keysat — Radii</title>
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body { margin: 0; padding: 22px; font-family: var(--font-body); background: var(--bg-page); }
|
||||
.grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 14px; }
|
||||
.item { display: flex; flex-direction: column; align-items: center; gap: 8px; }
|
||||
.box { width: 76px; height: 76px; background: #1E3A5F; box-shadow: var(--shadow-sm); }
|
||||
.name { font-family: var(--font-mono); font-size: 11px; color: var(--ink-700); }
|
||||
.px { font-family: var(--font-mono); font-size: 10px; color: var(--ink-400); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="grid">
|
||||
<div class="item"><div class="box" style="border-radius:3px"></div><span class="name">xs</span><span class="px">3px</span></div>
|
||||
<div class="item"><div class="box" style="border-radius:5px"></div><span class="name">sm</span><span class="px">5px</span></div>
|
||||
<div class="item"><div class="box" style="border-radius:8px"></div><span class="name">md ★</span><span class="px">8px (buttons)</span></div>
|
||||
<div class="item"><div class="box" style="border-radius:12px"></div><span class="name">lg ★</span><span class="px">12px (cards)</span></div>
|
||||
<div class="item"><div class="box" style="border-radius:18px"></div><span class="name">xl</span><span class="px">18px</span></div>
|
||||
<div class="item"><div class="box" style="border-radius:999px"></div><span class="name">pill</span><span class="px">tags only</span></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Keysat — Shadows</title>
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body { margin: 0; padding: 28px 24px; font-family: var(--font-body); background: var(--bg-page); }
|
||||
.grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 18px; }
|
||||
.item { display: flex; flex-direction: column; align-items: center; gap: 8px; }
|
||||
.box { width: 100px; height: 64px; background: #FBF9F2; border-radius: 8px; border: 1px solid rgba(14,31,51,0.08); }
|
||||
.name { font-family: var(--font-mono); font-size: 11px; color: var(--ink-700); }
|
||||
.desc { font-size: 10px; color: var(--ink-400); text-align:center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="grid">
|
||||
<div class="item"><div class="box" style="box-shadow:0 1px 1px rgba(14,31,51,0.04)"></div><span class="name">shadow-xs</span><span class="desc">resting</span></div>
|
||||
<div class="item"><div class="box" style="box-shadow:0 1px 2px rgba(14,31,51,0.06), 0 1px 1px rgba(14,31,51,0.03)"></div><span class="name">shadow-sm</span><span class="desc">cards</span></div>
|
||||
<div class="item"><div class="box" style="box-shadow:0 2px 4px rgba(14,31,51,0.06), 0 4px 12px rgba(14,31,51,0.06)"></div><span class="name">shadow-md</span><span class="desc">elevated</span></div>
|
||||
<div class="item"><div class="box" style="box-shadow:0 4px 8px rgba(14,31,51,0.07), 0 12px 32px rgba(14,31,51,0.10)"></div><span class="name">shadow-lg</span><span class="desc">popovers</span></div>
|
||||
<div class="item"><div class="box" style="box-shadow:0 8px 16px rgba(14,31,51,0.10), 0 24px 64px rgba(14,31,51,0.14)"></div><span class="name">shadow-xl</span><span class="desc">modals</span></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Keysat — Spacing</title>
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body { margin: 0; padding: 22px; font-family: var(--font-body); background: var(--bg-page); }
|
||||
.row { display: flex; align-items: center; gap: 14px; padding: 6px 0; }
|
||||
.name { font-family: var(--font-mono); font-size: 12px; color: var(--ink-700); width: 64px; }
|
||||
.px { font-family: var(--font-mono); font-size: 11px; color: var(--ink-400); width: 38px; }
|
||||
.bar { background: #1E3A5F; height: 14px; border-radius: 2px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="row"><span class="name">--sp-1</span><span class="px">4px</span><div class="bar" style="width:4px"></div></div>
|
||||
<div class="row"><span class="name">--sp-2</span><span class="px">8px</span><div class="bar" style="width:8px"></div></div>
|
||||
<div class="row"><span class="name">--sp-3</span><span class="px">12</span><div class="bar" style="width:12px"></div></div>
|
||||
<div class="row"><span class="name">--sp-4</span><span class="px">16</span><div class="bar" style="width:16px"></div></div>
|
||||
<div class="row"><span class="name">--sp-5</span><span class="px">20</span><div class="bar" style="width:20px"></div></div>
|
||||
<div class="row"><span class="name">--sp-6</span><span class="px">24</span><div class="bar" style="width:24px"></div></div>
|
||||
<div class="row"><span class="name">--sp-7</span><span class="px">32</span><div class="bar" style="width:32px"></div></div>
|
||||
<div class="row"><span class="name">--sp-8</span><span class="px">40</span><div class="bar" style="width:40px"></div></div>
|
||||
<div class="row"><span class="name">--sp-9</span><span class="px">56</span><div class="bar" style="width:56px"></div></div>
|
||||
<div class="row"><span class="name">--sp-10</span><span class="px">72</span><div class="bar" style="width:72px"></div></div>
|
||||
<div class="row"><span class="name">--sp-11</span><span class="px">96</span><div class="bar" style="width:96px"></div></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Keysat — Body & Mono Type</title>
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body { margin: 0; padding: 22px 24px; font-family: var(--font-body); background: var(--bg-page); color: var(--ink-900); }
|
||||
.meta { font-family: var(--font-mono); font-size: 11px; color: var(--ink-500); margin-top: 4px; }
|
||||
.stack > div { margin-bottom: 14px; }
|
||||
.lead { font-size: 18px; line-height: 1.5; color: var(--ink-700); }
|
||||
.body { font-size: 15px; line-height: 1.55; color: var(--ink-700); }
|
||||
.small { font-size: 13.5px; line-height: 1.5; color: var(--ink-500); }
|
||||
.mono { font-family: var(--font-mono); font-size: 13px; color: var(--ink-900); }
|
||||
.key { background: white; border: 1px solid var(--border-1); padding: 8px 12px; border-radius: 6px; display: inline-block; font-family: var(--font-mono); font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="stack">
|
||||
<div><div class="lead">Buyers pay in Bitcoin via your own BTCPay. Your software verifies signed keys offline.</div><div class="meta">Inter · 400 · lead · 18/27</div></div>
|
||||
<div><div class="body">A complete sell-your-software stack, sovereign end-to-end. No SaaS, no middleman.</div><div class="meta">Inter · 400 · body · 15/23</div></div>
|
||||
<div><div class="small">Backed up automatically by StartOS as part of your normal backup routine.</div><div class="meta">Inter · 400 · small · 13.5/20</div></div>
|
||||
<div><span class="key">KS-9F2A-7C41-XK22-6D8E</span><div class="meta">JetBrains Mono · 500 · license key · 13</div></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Keysat — Display Type</title>
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body { margin: 0; padding: 22px 24px; font-family: var(--font-body); background: var(--bg-page); color: var(--ink-900); }
|
||||
.meta { font-family: var(--font-mono); font-size: 11px; color: var(--ink-500); margin-top: 4px; }
|
||||
.stack > div { margin-bottom: 14px; }
|
||||
.display { font-family: var(--font-display); font-weight: 800; line-height: 1.02; letter-spacing: -0.025em; }
|
||||
.h1 { font-family: var(--font-display); font-size: 44px; font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
|
||||
.h2 { font-family: var(--font-display); font-size: 32px; font-weight: 700; line-height: 1.15; letter-spacing: -0.02em; }
|
||||
.eyebrow { font-family: var(--font-body); font-size: 11.5px; font-weight: 600; letter-spacing: 0.18em; text-transform: uppercase; color: var(--gold-700); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="stack">
|
||||
<div>
|
||||
<div class="eyebrow">Display</div>
|
||||
<div class="display" style="font-size: 56px;">Sovereign by default.</div>
|
||||
<div class="meta">Archivo · 800 · 56/58 · -2.5%</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="h1">Self-hosted licensing for indie creators</div>
|
||||
<div class="meta">Archivo · 800 · h1 · 44/48</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="h2">You own the signing key</div>
|
||||
<div class="meta">Archivo · 700 · h2 · 32/37</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
# Keysat — Dashboard UI Kit
|
||||
|
||||
The creator's admin panel. Runs on the creator's own Start9. Manages products, policies, license keys, customers, discount codes, and the audit log.
|
||||
|
||||
## Files
|
||||
|
||||
- `index.html` — entry view (Overview / dashboard home).
|
||||
- `licenses.html` — license list, with row-level actions.
|
||||
- `license-detail.html` — single license: certificate-style header, customer info, audit timeline, revoke action.
|
||||
- `new-product.html` — create-product flow (product details + policy + price).
|
||||
- `signin.html` — admin sign-in (paste admin API key).
|
||||
|
||||
## Components (inline in each page)
|
||||
|
||||
- **Sidebar** — wordmark, primary nav (Overview, Products, Licenses, Customers, Discounts, Audit, Settings), BTCPay connection status footer.
|
||||
- **Topbar** — page title, breadcrumb, primary action button, search.
|
||||
- **Stat cards** — KPI tiles (active licenses, sales 30d, sats earned).
|
||||
- **Table** — license list, customer list. 52px rows, mono key column, status badge column.
|
||||
- **Drawer / detail header** — certificate motif borrowed from the marketing hero.
|
||||
- **Empty state** — gold-bordered cream card with a single Lucide icon.
|
||||
|
||||
## Iconography
|
||||
|
||||
Lucide via CDN. Stroke 1.75px, 18px in nav, 16px inline.
|
||||
|
||||
## Disclaimers
|
||||
|
||||
- The "Settings" tab from the user's brief was scoped down to **operator settings** (operator name, public key, BTCPay connection). Payouts removed because BTCPay handles money.
|
||||
- All data is fake.
|
||||
@@ -0,0 +1,206 @@
|
||||
/* Shared dashboard chrome — links to ../../colors_and_type.css for tokens */
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
color: var(--ink-900);
|
||||
background: var(--cream-100);
|
||||
background-image:
|
||||
radial-gradient(rgba(14,31,51,0.022) 1px, transparent 1px),
|
||||
radial-gradient(rgba(138,111,61,0.020) 1px, transparent 1px);
|
||||
background-size: 3px 3px, 7px 7px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: var(--navy-800); text-decoration: none; }
|
||||
|
||||
/* ---------- Layout ---------- */
|
||||
.app { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }
|
||||
|
||||
/* ---------- Sidebar ---------- */
|
||||
.sidebar {
|
||||
background: var(--navy-950);
|
||||
color: var(--cream-200);
|
||||
padding: 24px 14px;
|
||||
display: flex; flex-direction: column;
|
||||
border-right: 1px solid var(--navy-900);
|
||||
}
|
||||
.sidebar .brand {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
font-family: var(--font-display); font-weight: 500; font-size: 14px; letter-spacing: 0.28em; text-transform: uppercase;
|
||||
color: var(--cream-50);
|
||||
padding: 0 8px 22px;
|
||||
border-bottom: 1px solid rgba(245,241,232,0.10);
|
||||
margin-bottom: 14px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.sidebar .brand img { width: 26px; height: 26px; }
|
||||
.sidebar .group-label {
|
||||
font-size: 10px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase;
|
||||
color: var(--gold-400); padding: 16px 10px 8px;
|
||||
}
|
||||
.sidebar a.nav {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 9px 10px; border-radius: 6px;
|
||||
font-size: 13.5px; color: rgba(245,241,232,0.72);
|
||||
transition: all 120ms;
|
||||
}
|
||||
.sidebar a.nav:hover { background: rgba(245,241,232,0.06); color: var(--cream-50); }
|
||||
.sidebar a.nav.active { background: var(--navy-800); color: var(--cream-50); }
|
||||
.sidebar a.nav [data-lucide] { width: 16px; height: 16px; }
|
||||
.sidebar a.nav .count {
|
||||
margin-left: auto; font-family: var(--font-mono); font-size: 11px;
|
||||
background: rgba(245,241,232,0.10); color: var(--cream-200);
|
||||
padding: 1px 7px; border-radius: 999px;
|
||||
}
|
||||
.sidebar a.nav.active .count { background: var(--gold-500); color: var(--navy-950); }
|
||||
.sidebar .footer {
|
||||
margin-top: auto; padding: 14px 10px; border-top: 1px solid rgba(245,241,232,0.10);
|
||||
font-size: 12px; color: rgba(245,241,232,0.55); display: flex; gap: 10px; align-items: center;
|
||||
}
|
||||
.sidebar .footer .dot { width: 7px; height: 7px; border-radius: 50%; background: #2D7A5F; box-shadow: 0 0 0 3px rgba(45,122,95,0.25); }
|
||||
|
||||
/* ---------- Main ---------- */
|
||||
.main { display: flex; flex-direction: column; min-width: 0; }
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
padding: 18px 32px; border-bottom: 1px solid var(--border-1);
|
||||
background: rgba(251,249,242,0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
position: sticky; top: 0; z-index: 5;
|
||||
}
|
||||
.topbar .crumb { font-size: 12.5px; color: var(--ink-500); }
|
||||
.topbar h1 {
|
||||
font-family: var(--font-display); font-weight: 700; font-size: 22px;
|
||||
letter-spacing: -0.015em; margin: 2px 0 0; color: var(--navy-950);
|
||||
}
|
||||
.topbar .search {
|
||||
margin-left: auto;
|
||||
position: relative; width: 280px;
|
||||
}
|
||||
.topbar .search input {
|
||||
width: 100%; padding: 8px 12px 8px 32px;
|
||||
font-family: var(--font-body); font-size: 13px;
|
||||
border: 1px solid var(--border-1);
|
||||
border-radius: 8px; background: var(--cream-50);
|
||||
color: var(--ink-900);
|
||||
}
|
||||
.topbar .search input:focus { outline: none; border-color: var(--navy-700); box-shadow: 0 0 0 3px rgba(30,58,95,0.15); }
|
||||
.topbar .search [data-lucide] { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); width: 14px; height: 14px; color: var(--ink-400); }
|
||||
.topbar .topbar-actions { display: flex; gap: 8px; align-items: center; }
|
||||
|
||||
.content { padding: 28px 32px 64px; max-width: 1280px; }
|
||||
|
||||
/* ---------- Buttons ---------- */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
font-family: var(--font-body); font-weight: 600; font-size: 13px;
|
||||
padding: 8px 14px; border-radius: 7px; border: 1px solid transparent;
|
||||
cursor: pointer; transition: all 120ms; line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn [data-lucide] { width: 14px; height: 14px; }
|
||||
.btn.lg { font-size: 14px; padding: 11px 18px; }
|
||||
.btn.sm { font-size: 12px; padding: 6px 10px; }
|
||||
.btn.primary { background: var(--navy-800); color: var(--cream-50); border-color: var(--navy-800); }
|
||||
.btn.primary:hover { background: var(--navy-900); border-color: var(--navy-900); }
|
||||
.btn.secondary { background: var(--cream-50); color: var(--navy-900); border-color: var(--border-2); }
|
||||
.btn.secondary:hover { background: var(--cream-200); }
|
||||
.btn.ghost { background: transparent; color: var(--navy-900); }
|
||||
.btn.ghost:hover { background: rgba(14,31,51,0.06); }
|
||||
.btn.danger { color: var(--danger); border-color: rgba(178,58,58,0.3); background: transparent; }
|
||||
.btn.danger:hover { background: var(--danger-bg); }
|
||||
|
||||
/* ---------- Cards ---------- */
|
||||
.card {
|
||||
background: var(--cream-50);
|
||||
border: 1px solid var(--border-1);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
.card .card-head {
|
||||
padding: 14px 18px; border-bottom: 1px solid var(--border-1);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.card .card-head h3 {
|
||||
font-family: var(--font-display); font-weight: 700; font-size: 15px;
|
||||
margin: 0; letter-spacing: -0.01em; color: var(--navy-950);
|
||||
}
|
||||
|
||||
/* ---------- Stats ---------- */
|
||||
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 24px; }
|
||||
.stat {
|
||||
background: var(--cream-50); border: 1px solid var(--border-1);
|
||||
border-radius: 10px; padding: 18px 18px 16px;
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.stat::before {
|
||||
content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 2px;
|
||||
background: var(--gold-500); opacity: 0;
|
||||
}
|
||||
.stat.featured::before { opacity: 1; }
|
||||
.stat .label {
|
||||
font-size: 11px; font-weight: 700; letter-spacing: 0.14em;
|
||||
text-transform: uppercase; color: var(--ink-500); margin-bottom: 8px;
|
||||
}
|
||||
.stat .value {
|
||||
font-family: var(--font-display); font-weight: 500; font-size: 30px;
|
||||
color: var(--navy-950); letter-spacing: -0.022em; line-height: 1;
|
||||
}
|
||||
.stat .value .unit { font-family: var(--font-body); font-size: 13px; font-weight: 600; color: var(--ink-500); margin-left: 6px; }
|
||||
.stat .delta { font-size: 12px; color: var(--success); margin-top: 8px; font-weight: 600; }
|
||||
.stat .delta.down { color: var(--danger); }
|
||||
.stat .sub { font-size: 12px; color: var(--ink-500); margin-top: 6px; }
|
||||
|
||||
/* ---------- Table ---------- */
|
||||
table.t {
|
||||
width: 100%; border-collapse: separate; border-spacing: 0;
|
||||
background: var(--cream-50); border: 1px solid var(--border-1);
|
||||
border-radius: 10px; overflow: hidden;
|
||||
}
|
||||
table.t thead th {
|
||||
text-align: left; font-size: 11px; font-weight: 700;
|
||||
letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-500);
|
||||
padding: 12px 16px; background: var(--cream-100);
|
||||
border-bottom: 1px solid var(--border-1);
|
||||
}
|
||||
table.t tbody td {
|
||||
padding: 14px 16px; border-bottom: 1px solid var(--border-1);
|
||||
font-size: 13.5px; color: var(--ink-700); vertical-align: middle;
|
||||
}
|
||||
table.t tbody tr:last-child td { border-bottom: 0; }
|
||||
table.t tbody tr:hover { background: var(--cream-100); cursor: pointer; }
|
||||
table.t .key { font-family: var(--font-mono); font-size: 12.5px; color: var(--navy-900); font-weight: 600; }
|
||||
table.t .product { font-weight: 600; color: var(--navy-950); }
|
||||
table.t .meta { color: var(--ink-500); font-size: 12px; }
|
||||
|
||||
/* ---------- Badges ---------- */
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 600;
|
||||
padding: 2px 9px; border-radius: 999px; line-height: 1.5; border: 1px solid transparent;
|
||||
}
|
||||
.b-success { background: var(--success-bg); color: #205c47; border-color: rgba(45,122,95,0.25); }
|
||||
.b-warning { background: var(--warning-bg); color: #7a5814; border-color: rgba(184,134,31,0.3); }
|
||||
.b-danger { background: var(--danger-bg); color: #8a2828; border-color: rgba(178,58,58,0.25); }
|
||||
.b-info { background: var(--navy-100); color: var(--navy-800); border-color: rgba(30,58,95,0.20); }
|
||||
.b-neutral { background: var(--cream-200); color: var(--ink-700); border-color: var(--border-1); }
|
||||
.b-gold { background: transparent; color: var(--gold-700); border-color: var(--gold-500); }
|
||||
.dot { width: 6px; height: 6px; border-radius: 50%; }
|
||||
|
||||
/* ---------- Forms ---------- */
|
||||
.field { margin-bottom: 14px; }
|
||||
.field label.lbl { display: block; font-size: 12.5px; font-weight: 600; color: var(--ink-700); margin-bottom: 6px; }
|
||||
.field .hint { font-size: 12px; color: var(--ink-500); margin-top: 5px; }
|
||||
.input, .select {
|
||||
width: 100%; padding: 9px 12px; font-family: var(--font-body); font-size: 13.5px;
|
||||
border: 1px solid var(--border-2); border-radius: 7px; background: #FFFFFF;
|
||||
color: var(--ink-900); transition: all 120ms;
|
||||
}
|
||||
.input:focus, .select:focus { outline: none; border-color: var(--navy-700); box-shadow: 0 0 0 3px rgba(30,58,95,0.18); }
|
||||
.input.mono { font-family: var(--font-mono); font-size: 13px; }
|
||||
|
||||
.eyebrow {
|
||||
font-size: 10.5px; font-weight: 700; letter-spacing: 0.18em;
|
||||
text-transform: uppercase; color: var(--gold-700);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Keysat — Overview</title>
|
||||
<link rel="stylesheet" href="../../colors_and_type.css">
|
||||
<link rel="stylesheet" href="dash.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<aside class="sidebar">
|
||||
<div class="brand"><img src="../../assets/keysat-mark.svg" alt=""><span>Keysat</span></div>
|
||||
<a class="nav active" href="index.html"><i data-lucide="layout-dashboard"></i>Overview</a>
|
||||
<a class="nav" href="#"><i data-lucide="package"></i>Products<span class="count">3</span></a>
|
||||
<a class="nav" href="licenses.html"><i data-lucide="key-round"></i>Licenses<span class="count">42</span></a>
|
||||
<a class="nav" href="#"><i data-lucide="users"></i>Customers<span class="count">38</span></a>
|
||||
<a class="nav" href="#"><i data-lucide="tag"></i>Discount codes</a>
|
||||
<div class="group-label">System</div>
|
||||
<a class="nav" href="#"><i data-lucide="scroll-text"></i>Audit log</a>
|
||||
<a class="nav" href="#"><i data-lucide="webhook"></i>Webhooks</a>
|
||||
<a class="nav" href="#"><i data-lucide="settings-2"></i>Settings</a>
|
||||
<div class="footer"><span class="dot"></span><div><div style="color: var(--cream-50); font-weight: 600">BTCPay connected</div><div>store: aurora-software</div></div></div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<div class="crumb">Workspace · aurora-software</div>
|
||||
<h1>Overview</h1>
|
||||
</div>
|
||||
<div class="search"><i data-lucide="search"></i><input placeholder="Search licenses, customers, products"></div>
|
||||
<div class="topbar-actions">
|
||||
<button class="btn secondary"><i data-lucide="download"></i>Export</button>
|
||||
<button class="btn primary"><i data-lucide="plus"></i>New product</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
<div class="stats">
|
||||
<div class="stat featured">
|
||||
<div class="label">Active licenses</div>
|
||||
<div class="value">42</div>
|
||||
<div class="delta">+5 this month</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="label">Sales · 30 days</div>
|
||||
<div class="value">12 <span class="unit">sales</span></div>
|
||||
<div class="delta">+33% vs prev</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="label">Sats earned · 30d</div>
|
||||
<div class="value">412,500</div>
|
||||
<div class="sub">≈ $247.32</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="label">Conversion</div>
|
||||
<div class="value">8.4 <span class="unit">%</span></div>
|
||||
<div class="delta down">−1.2% vs prev</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1.6fr 1fr; gap: 18px;">
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Recent licenses</h3><a href="licenses.html" class="btn ghost sm">View all <i data-lucide="arrow-right"></i></a></div>
|
||||
<table class="t" style="border:0; border-radius: 0;">
|
||||
<thead><tr><th>Key</th><th>Product</th><th>Customer</th><th>Status</th><th>Issued</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="key">KS-9F2A-7C41-XK22-6D8E</td><td class="product">Sundial 2.0</td><td>nina@dial.studio</td><td><span class="badge b-success"><span class="dot" style="background:#2D7A5F"></span>Active</span></td><td class="meta">2 hours ago</td></tr>
|
||||
<tr><td class="key">KS-A14C-PT09-LM31-R7Q4</td><td class="product">Sundial Pro</td><td>m@labry.dev</td><td><span class="badge b-success"><span class="dot" style="background:#2D7A5F"></span>Active</span></td><td class="meta">Yesterday</td></tr>
|
||||
<tr><td class="key">KS-T2X8-6K43-QQ91-WE0M</td><td class="product">Sundial 2.0</td><td>jo@kestrel.fm</td><td><span class="badge b-warning"><span class="dot" style="background:#B8861F"></span>Trial</span></td><td class="meta">2d ago</td></tr>
|
||||
<tr><td class="key">KS-BX9D-MM21-NU45-7F3R</td><td class="product">Sundial 2.0</td><td>ari@northpath.io</td><td><span class="badge b-success"><span class="dot" style="background:#2D7A5F"></span>Active</span></td><td class="meta">3d ago</td></tr>
|
||||
<tr><td class="key">KS-PW45-VR82-XA61-9K0L</td><td class="product">Sundial Pro</td><td>tom@workhorse.app</td><td><span class="badge b-danger"><span class="dot" style="background:#B23A3A"></span>Revoked</span></td><td class="meta">5d ago</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 14px;">
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Top products</h3></div>
|
||||
<div style="padding: 12px 16px;">
|
||||
<div style="display: flex; align-items: center; padding: 10px 0; gap: 12px; border-bottom: 1px solid var(--border-1);">
|
||||
<div style="flex: 1;"><div style="font-weight: 600; color: var(--navy-950)">Sundial 2.0</div><div style="font-size: 12px; color: var(--ink-500)">28 active · 50,000 sats</div></div>
|
||||
<div style="font-family: var(--font-mono); font-size: 12.5px; color: var(--navy-900); font-weight: 600">1.4M sats</div>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; padding: 10px 0; gap: 12px; border-bottom: 1px solid var(--border-1);">
|
||||
<div style="flex: 1;"><div style="font-weight: 600; color: var(--navy-950)">Sundial Pro</div><div style="font-size: 12px; color: var(--ink-500)">11 active · 200,000 sats</div></div>
|
||||
<div style="font-family: var(--font-mono); font-size: 12.5px; color: var(--navy-900); font-weight: 600">2.2M sats</div>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; padding: 10px 0; gap: 12px;">
|
||||
<div style="flex: 1;"><div style="font-weight: 600; color: var(--navy-950)">Aurora Plugin</div><div style="font-size: 12px; color: var(--ink-500)">3 active · 75,000 sats</div></div>
|
||||
<div style="font-family: var(--font-mono); font-size: 12.5px; color: var(--navy-900); font-weight: 600">225k sats</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="background: var(--cream-100); border-style: dashed;">
|
||||
<div style="padding: 18px;">
|
||||
<div class="eyebrow" style="margin-bottom: 6px;">Tip</div>
|
||||
<div style="font-family: var(--font-display); font-weight: 700; font-size: 15px; color: var(--navy-950); margin-bottom: 4px; letter-spacing: -0.01em;">Embed your public key</div>
|
||||
<p style="font-size: 13px; color: var(--ink-700); margin: 0 0 12px; line-height: 1.5;">Paste this into your app's source. Verifies signatures offline.</p>
|
||||
<div style="background: var(--navy-950); color: var(--cream-50); padding: 10px 12px; border-radius: 7px; font-family: var(--font-mono); font-size: 12px; display: flex; gap: 10px; align-items: center; justify-content: space-between;">
|
||||
<span>mz7q8r4t1v…h3k2pXq9wL</span>
|
||||
<button class="btn sm" style="background: rgba(245,241,232,0.10); color: var(--cream-50); border: 0;">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script>lucide.createIcons();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,106 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Keysat — License KS-9F2A-7C41-XK22-6D8E</title>
|
||||
<link rel="stylesheet" href="../../colors_and_type.css">
|
||||
<link rel="stylesheet" href="dash.css">
|
||||
<style>
|
||||
.detail-grid { display: grid; grid-template-columns: 1.4fr 1fr; gap: 18px; align-items: start; }
|
||||
.cert-head { background: var(--cream-50); border: 1px solid var(--border-1); border-radius: 12px; box-shadow: 0 0 0 1px var(--gold-500) inset, var(--shadow-sm); padding: 28px; position: relative; }
|
||||
.cert-head::before, .cert-head::after { content: ''; position: absolute; left: 14px; right: 14px; height: 1px; background: var(--gold-500); opacity: 0.4; }
|
||||
.cert-head::before { top: 14px; } .cert-head::after { bottom: 14px; }
|
||||
.cert-stamp { font-size: 10px; font-weight: 700; letter-spacing: 0.22em; text-transform: uppercase; color: var(--gold-700); margin-bottom: 14px; }
|
||||
.cert-key { font-family: var(--font-mono); font-size: 22px; font-weight: 600; color: var(--navy-950); letter-spacing: 0.02em; margin-bottom: 8px; }
|
||||
.cert-product { font-family: var(--font-display); font-weight: 700; font-size: 18px; color: var(--ink-700); margin-bottom: 18px; }
|
||||
.cert-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; padding-top: 16px; border-top: 1px dashed rgba(14,31,51,0.18); }
|
||||
.cert-grid .field { font-size: 10.5px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--ink-500); margin-bottom: 4px; }
|
||||
.cert-grid .value { font-family: var(--font-mono); font-size: 13px; color: var(--navy-900); font-weight: 500; }
|
||||
.timeline { padding: 4px 16px; }
|
||||
.timeline .item { display: grid; grid-template-columns: 18px 1fr; gap: 10px; padding: 12px 0; border-bottom: 1px solid var(--border-1); }
|
||||
.timeline .item:last-child { border-bottom: 0; }
|
||||
.timeline .marker { width: 8px; height: 8px; border-radius: 50%; background: var(--navy-800); margin-top: 6px; }
|
||||
.timeline .marker.gold { background: var(--gold-500); }
|
||||
.timeline .marker.danger { background: var(--danger); }
|
||||
.timeline .head { font-size: 13px; font-weight: 600; color: var(--navy-950); }
|
||||
.timeline .meta { font-size: 12px; color: var(--ink-500); margin-top: 2px; font-family: var(--font-mono); }
|
||||
.timeline .body { font-size: 12.5px; color: var(--ink-700); margin-top: 4px; }
|
||||
.info-row { display: flex; justify-content: space-between; padding: 10px 16px; border-bottom: 1px solid var(--border-1); font-size: 13px; }
|
||||
.info-row:last-child { border-bottom: 0; }
|
||||
.info-row .k { color: var(--ink-500); }
|
||||
.info-row .v { color: var(--navy-900); font-weight: 500; }
|
||||
.actions-row { display: flex; gap: 8px; margin-top: 16px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<aside class="sidebar">
|
||||
<div class="brand"><img src="../../assets/keysat-mark.svg" alt=""><span>Keysat</span></div>
|
||||
<a class="nav" href="index.html"><i data-lucide="layout-dashboard"></i>Overview</a>
|
||||
<a class="nav" href="#"><i data-lucide="package"></i>Products<span class="count">3</span></a>
|
||||
<a class="nav active" href="licenses.html"><i data-lucide="key-round"></i>Licenses<span class="count">42</span></a>
|
||||
<a class="nav" href="#"><i data-lucide="users"></i>Customers<span class="count">38</span></a>
|
||||
<a class="nav" href="#"><i data-lucide="tag"></i>Discount codes</a>
|
||||
<div class="group-label">System</div>
|
||||
<a class="nav" href="#"><i data-lucide="scroll-text"></i>Audit log</a>
|
||||
<a class="nav" href="#"><i data-lucide="webhook"></i>Webhooks</a>
|
||||
<a class="nav" href="#"><i data-lucide="settings-2"></i>Settings</a>
|
||||
<div class="footer"><span class="dot"></span><div><div style="color: var(--cream-50); font-weight: 600">BTCPay connected</div><div>store: aurora-software</div></div></div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<div><div class="crumb"><a href="licenses.html">Licenses</a> · KS-9F2A-7C41-XK22-6D8E</div><h1>License detail</h1></div>
|
||||
<div class="topbar-actions">
|
||||
<button class="btn secondary"><i data-lucide="copy"></i>Copy key</button>
|
||||
<button class="btn secondary"><i data-lucide="mail"></i>Resend email</button>
|
||||
<button class="btn danger"><i data-lucide="x-circle"></i>Revoke</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="content">
|
||||
<div class="cert-head">
|
||||
<div class="cert-stamp">— Certificate of License · Active —</div>
|
||||
<div class="cert-key">KS-9F2A-7C41-XK22-6D8E</div>
|
||||
<div class="cert-product">Sundial 2.0 · default policy</div>
|
||||
<div class="cert-grid">
|
||||
<div><div class="field">Issued</div><div class="value">2026-04-22</div></div>
|
||||
<div><div class="field">Expires</div><div class="value">2027-04-22</div></div>
|
||||
<div><div class="field">Seats</div><div class="value">1 of 1</div></div>
|
||||
<div><div class="field">Trial</div><div class="value">No</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-grid" style="margin-top: 18px;">
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Audit timeline</h3></div>
|
||||
<div class="timeline">
|
||||
<div class="item"><div class="marker gold"></div><div><div class="head">License issued</div><div class="meta">2026-04-22 · 14:32 · BTCPay invoice INV-9F2A</div><div class="body">Signed with issuer key mz7q8r4t1v…h3k2pXq9wL.</div></div></div>
|
||||
<div class="item"><div class="marker"></div><div><div class="head">Payment confirmed</div><div class="meta">2026-04-22 · 14:31 · 50,000 sats · Lightning</div><div class="body">Settled in 1 confirmation. Funds routed to wallet "aurora".</div></div></div>
|
||||
<div class="item"><div class="marker"></div><div><div class="head">Invoice created</div><div class="meta">2026-04-22 · 14:29</div><div class="body">Buyer landed on purchase URL from /pricing.</div></div></div>
|
||||
<div class="item"><div class="marker"></div><div><div class="head">First verification</div><div class="meta">2026-04-22 · 15:11 · 10.0.4.118</div><div class="body">Sundial v2.0.3 verified key offline at startup.</div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 14px;">
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Customer</h3></div>
|
||||
<div class="info-row"><span class="k">Email</span><span class="v">nina@dial.studio</span></div>
|
||||
<div class="info-row"><span class="k">npub</span><span class="v" style="font-family: var(--font-mono); font-size: 12px">npub1aw…q4t8</span></div>
|
||||
<div class="info-row"><span class="k">First seen</span><span class="v">2 hours ago</span></div>
|
||||
<div class="info-row"><span class="k">Other licenses</span><span class="v">1 (Aurora Plugin)</span></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Policy</h3></div>
|
||||
<div class="info-row"><span class="k">Slug</span><span class="v" style="font-family: var(--font-mono)">default</span></div>
|
||||
<div class="info-row"><span class="k">Duration</span><span class="v">1 year</span></div>
|
||||
<div class="info-row"><span class="k">Seats</span><span class="v">1</span></div>
|
||||
<div class="info-row"><span class="k">Entitlements</span><span class="v">core, sync, export</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script>lucide.createIcons();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,69 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Keysat — Licenses</title>
|
||||
<link rel="stylesheet" href="../../colors_and_type.css">
|
||||
<link rel="stylesheet" href="dash.css">
|
||||
<style>
|
||||
.toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
|
||||
.chip { font-size: 12.5px; padding: 6px 12px; border-radius: 999px; background: var(--cream-50); border: 1px solid var(--border-1); color: var(--ink-700); cursor: pointer; }
|
||||
.chip.active { background: var(--navy-800); color: var(--cream-50); border-color: var(--navy-800); }
|
||||
.chip .count { opacity: 0.6; margin-left: 4px; }
|
||||
.filter-spacer { flex: 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<aside class="sidebar">
|
||||
<div class="brand"><img src="../../assets/keysat-mark.svg" alt=""><span>Keysat</span></div>
|
||||
<a class="nav" href="index.html"><i data-lucide="layout-dashboard"></i>Overview</a>
|
||||
<a class="nav" href="#"><i data-lucide="package"></i>Products<span class="count">3</span></a>
|
||||
<a class="nav active" href="licenses.html"><i data-lucide="key-round"></i>Licenses<span class="count">42</span></a>
|
||||
<a class="nav" href="#"><i data-lucide="users"></i>Customers<span class="count">38</span></a>
|
||||
<a class="nav" href="#"><i data-lucide="tag"></i>Discount codes</a>
|
||||
<div class="group-label">System</div>
|
||||
<a class="nav" href="#"><i data-lucide="scroll-text"></i>Audit log</a>
|
||||
<a class="nav" href="#"><i data-lucide="webhook"></i>Webhooks</a>
|
||||
<a class="nav" href="#"><i data-lucide="settings-2"></i>Settings</a>
|
||||
<div class="footer"><span class="dot"></span><div><div style="color: var(--cream-50); font-weight: 600">BTCPay connected</div><div>store: aurora-software</div></div></div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<div><div class="crumb">Workspace · aurora-software</div><h1>Licenses</h1></div>
|
||||
<div class="search"><i data-lucide="search"></i><input placeholder="Search by key, email, product…"></div>
|
||||
<div class="topbar-actions">
|
||||
<button class="btn secondary"><i data-lucide="download"></i>Export</button>
|
||||
<button class="btn primary"><i data-lucide="plus"></i>Issue license</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="content">
|
||||
<div class="toolbar">
|
||||
<span class="chip active">All<span class="count">42</span></span>
|
||||
<span class="chip">Active<span class="count">35</span></span>
|
||||
<span class="chip">Trial<span class="count">4</span></span>
|
||||
<span class="chip">Expired<span class="count">2</span></span>
|
||||
<span class="chip">Revoked<span class="count">1</span></span>
|
||||
<div class="filter-spacer"></div>
|
||||
<button class="btn ghost sm"><i data-lucide="filter"></i>Filter</button>
|
||||
<button class="btn ghost sm"><i data-lucide="arrow-up-down"></i>Sort: newest</button>
|
||||
</div>
|
||||
<table class="t">
|
||||
<thead><tr><th>License key</th><th>Product</th><th>Customer</th><th>Status</th><th>Expires</th><th>Issued</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="key">KS-9F2A-7C41-XK22-6D8E</td><td class="product">Sundial 2.0</td><td>nina@dial.studio</td><td><span class="badge b-success"><span class="dot" style="background:#2D7A5F"></span>Active</span></td><td class="meta">Apr 2027</td><td class="meta">2 hours ago</td><td><i data-lucide="more-horizontal" style="color:var(--ink-400)"></i></td></tr>
|
||||
<tr><td class="key">KS-A14C-PT09-LM31-R7Q4</td><td class="product">Sundial Pro</td><td>m@labry.dev</td><td><span class="badge b-success"><span class="dot" style="background:#2D7A5F"></span>Active</span></td><td class="meta"><span class="badge b-gold">Lifetime</span></td><td class="meta">Yesterday</td><td><i data-lucide="more-horizontal" style="color:var(--ink-400)"></i></td></tr>
|
||||
<tr><td class="key">KS-T2X8-6K43-QQ91-WE0M</td><td class="product">Sundial 2.0</td><td>jo@kestrel.fm</td><td><span class="badge b-warning"><span class="dot" style="background:#B8861F"></span>Trial</span></td><td class="meta">in 12 days</td><td class="meta">2d ago</td><td><i data-lucide="more-horizontal" style="color:var(--ink-400)"></i></td></tr>
|
||||
<tr><td class="key">KS-BX9D-MM21-NU45-7F3R</td><td class="product">Sundial 2.0</td><td>ari@northpath.io</td><td><span class="badge b-success"><span class="dot" style="background:#2D7A5F"></span>Active</span></td><td class="meta">Apr 2027</td><td class="meta">3d ago</td><td><i data-lucide="more-horizontal" style="color:var(--ink-400)"></i></td></tr>
|
||||
<tr><td class="key">KS-PW45-VR82-XA61-9K0L</td><td class="product">Sundial Pro</td><td>tom@workhorse.app</td><td><span class="badge b-danger"><span class="dot" style="background:#B23A3A"></span>Revoked</span></td><td class="meta">—</td><td class="meta">5d ago</td><td><i data-lucide="more-horizontal" style="color:var(--ink-400)"></i></td></tr>
|
||||
<tr><td class="key">KS-MN23-LP08-RR54-VV01</td><td class="product">Aurora Plugin</td><td>kate@kate.codes</td><td><span class="badge b-success"><span class="dot" style="background:#2D7A5F"></span>Active</span></td><td class="meta">Mar 2027</td><td class="meta">1w ago</td><td><i data-lucide="more-horizontal" style="color:var(--ink-400)"></i></td></tr>
|
||||
<tr><td class="key">KS-DD12-XK77-AA98-PQ45</td><td class="product">Sundial 2.0</td><td>raj@spinwheel.app</td><td><span class="badge b-success"><span class="dot" style="background:#2D7A5F"></span>Active</span></td><td class="meta">Mar 2027</td><td class="meta">1w ago</td><td><i data-lucide="more-horizontal" style="color:var(--ink-400)"></i></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script>lucide.createIcons();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,123 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Keysat — New product</title>
|
||||
<link rel="stylesheet" href="../../colors_and_type.css">
|
||||
<link rel="stylesheet" href="dash.css">
|
||||
<style>
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 380px; gap: 24px; align-items: start; }
|
||||
.panel-stack > * + * { margin-top: 14px; }
|
||||
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.preview-cert { background: var(--cream-50); border: 1px solid var(--border-1); border-radius: 12px; box-shadow: 0 0 0 1px var(--gold-500) inset, var(--shadow-sm); padding: 22px; }
|
||||
.preview-cert .stamp { font-size: 10px; font-weight: 700; letter-spacing: 0.22em; text-transform: uppercase; color: var(--gold-700); text-align: center; }
|
||||
.preview-cert .h { text-align: center; font-family: var(--font-display); font-weight: 500; font-size: 18px; color: var(--navy-950); margin: 8px 0 4px; }
|
||||
.preview-cert .sub { text-align: center; font-size: 12px; color: var(--ink-500); margin-bottom: 14px; }
|
||||
.preview-cert .field { font-size: 10.5px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--ink-500); margin-bottom: 3px; }
|
||||
.preview-cert .value { font-family: var(--font-mono); font-size: 13px; color: var(--navy-900); margin-bottom: 10px; }
|
||||
.seg { display: flex; gap: 0; border: 1px solid var(--border-2); border-radius: 7px; overflow: hidden; }
|
||||
.seg button { flex: 1; background: transparent; border: 0; padding: 9px 12px; font-size: 13px; color: var(--ink-700); cursor: pointer; font-family: var(--font-body); border-right: 1px solid var(--border-1); }
|
||||
.seg button:last-child { border-right: 0; }
|
||||
.seg button.on { background: var(--navy-800); color: var(--cream-50); font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<aside class="sidebar">
|
||||
<div class="brand"><img src="../../assets/keysat-mark.svg" alt=""><span>Keysat</span></div>
|
||||
<a class="nav" href="index.html"><i data-lucide="layout-dashboard"></i>Overview</a>
|
||||
<a class="nav active" href="#"><i data-lucide="package"></i>Products<span class="count">3</span></a>
|
||||
<a class="nav" href="licenses.html"><i data-lucide="key-round"></i>Licenses<span class="count">42</span></a>
|
||||
<a class="nav" href="#"><i data-lucide="users"></i>Customers<span class="count">38</span></a>
|
||||
<a class="nav" href="#"><i data-lucide="tag"></i>Discount codes</a>
|
||||
<div class="group-label">System</div>
|
||||
<a class="nav" href="#"><i data-lucide="scroll-text"></i>Audit log</a>
|
||||
<a class="nav" href="#"><i data-lucide="webhook"></i>Webhooks</a>
|
||||
<a class="nav" href="#"><i data-lucide="settings-2"></i>Settings</a>
|
||||
<div class="footer"><span class="dot"></span><div><div style="color: var(--cream-50); font-weight: 600">BTCPay connected</div></div></div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<div><div class="crumb"><a href="#">Products</a> · New</div><h1>New product</h1></div>
|
||||
<div class="topbar-actions">
|
||||
<button class="btn ghost">Cancel</button>
|
||||
<button class="btn secondary">Save as draft</button>
|
||||
<button class="btn primary"><i data-lucide="check"></i>Create product</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="content">
|
||||
<div class="form-grid">
|
||||
<div class="panel-stack">
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Product</h3></div>
|
||||
<div style="padding: 18px;">
|
||||
<div class="row-2">
|
||||
<div class="field"><label class="lbl">Product name</label><input class="input" value="Sundial 2.0"><div class="hint">Shown on receipts and the public purchase page.</div></div>
|
||||
<div class="field"><label class="lbl">Slug</label><input class="input mono" value="sundial-2"><div class="hint">Used in your purchase URL.</div></div>
|
||||
</div>
|
||||
<div class="field"><label class="lbl">Tagline</label><input class="input" value="A focused timer for deep work."></div>
|
||||
<div class="field"><label class="lbl">Description</label><textarea class="input" rows="3" style="resize: vertical; font-family: var(--font-body)">Sundial is a calm, single-window timer for deep work sessions. Sound design by Hauschka. macOS, Windows, Linux.</textarea></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Default policy</h3><span class="eyebrow">Drives the public purchase URL</span></div>
|
||||
<div style="padding: 18px;">
|
||||
<div class="row-2">
|
||||
<div class="field"><label class="lbl">Duration</label>
|
||||
<div class="seg">
|
||||
<button>30 days</button><button class="on">1 year</button><button>3 years</button><button>Lifetime</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field"><label class="lbl">Seats</label><input class="input" value="1"><div class="hint">Maximum machines per key.</div></div>
|
||||
</div>
|
||||
<div class="row-2">
|
||||
<div class="field"><label class="lbl">Trial available</label>
|
||||
<div class="seg"><button class="on">14 days</button><button>None</button></div>
|
||||
</div>
|
||||
<div class="field"><label class="lbl">Entitlements</label><input class="input mono" value="core, sync, export"><div class="hint">Comma-separated. Embedded in the signed key.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Price</h3></div>
|
||||
<div style="padding: 18px;">
|
||||
<div class="row-2">
|
||||
<div class="field"><label class="lbl">Amount</label><input class="input mono" value="50,000"></div>
|
||||
<div class="field"><label class="lbl">Unit</label>
|
||||
<div class="seg"><button class="on">sats</button><button>BTC</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint">≈ $30.00 USD at current rate. Updated every 30s from your BTCPay store.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-stack" style="position: sticky; top: 90px;">
|
||||
<div class="eyebrow">Preview</div>
|
||||
<div class="preview-cert">
|
||||
<div class="stamp">— Certificate of License —</div>
|
||||
<div class="h">Sundial 2.0</div>
|
||||
<div class="sub">default · 1 year · single seat</div>
|
||||
<div class="field">License key</div>
|
||||
<div class="value">KS-XXXX-XXXX-XXXX-XXXX</div>
|
||||
<div class="row-2">
|
||||
<div><div class="field">Price</div><div class="value">50,000 sats</div></div>
|
||||
<div><div class="field">Trial</div><div class="value">14 days</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="background: var(--cream-100); padding: 14px 16px; font-size: 12.5px; color: var(--ink-700);">
|
||||
<div style="display: flex; gap: 8px; align-items: start"><i data-lucide="info" style="width: 14px; height: 14px; color: var(--navy-700); flex-shrink: 0; margin-top: 2px;"></i>
|
||||
<span>Your public purchase URL will be<br><code style="font-family: var(--font-mono); color: var(--navy-900); font-size: 12px">aurora.keysat.local/buy/sundial-2</code></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script>lucide.createIcons();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,36 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Keysat — Sign in</title>
|
||||
<link rel="stylesheet" href="../../colors_and_type.css">
|
||||
<style>
|
||||
body { margin: 0; min-height: 100vh; font-family: var(--font-body); color: var(--ink-900); background: var(--cream-100); background-image: radial-gradient(rgba(14,31,51,0.025) 1px, transparent 1px), radial-gradient(rgba(138,111,61,0.022) 1px, transparent 1px); background-size: 3px 3px, 7px 7px; display: flex; align-items: center; justify-content: center; padding: 40px 20px; }
|
||||
.card { width: 420px; max-width: 100%; background: var(--cream-50); border: 1px solid var(--border-1); border-radius: 14px; box-shadow: 0 0 0 1px var(--gold-500) inset, var(--shadow-md); padding: 36px 36px 32px; position: relative; }
|
||||
.card::before { content: ''; position: absolute; left: 14px; right: 14px; top: 14px; height: 1px; background: var(--gold-500); opacity: 0.4; }
|
||||
.brand { display: flex; align-items: center; gap: 10px; justify-content: center; margin-bottom: 6px; }
|
||||
.brand img { width: 56px; height: 56px; }
|
||||
h1 { font-family: var(--font-display); font-weight: 500; font-size: 26px; letter-spacing: -0.02em; color: var(--navy-950); margin: 14px 0 4px; text-align: center; }
|
||||
.sub { text-align: center; font-size: 13.5px; color: var(--ink-500); margin-bottom: 24px; }
|
||||
.lbl { display: block; font-size: 12.5px; font-weight: 600; color: var(--ink-700); margin-bottom: 6px; }
|
||||
.input { width: 100%; padding: 11px 13px; font-family: var(--font-mono); font-size: 13px; border: 1px solid var(--border-2); border-radius: 8px; background: white; box-sizing: border-box; }
|
||||
.input:focus { outline: none; border-color: var(--navy-700); box-shadow: 0 0 0 3px rgba(30,58,95,0.18); }
|
||||
.btn { width: 100%; padding: 12px; background: var(--navy-800); color: var(--cream-50); border: 0; border-radius: 8px; font-family: var(--font-body); font-weight: 600; font-size: 14px; cursor: pointer; margin-top: 14px; transition: background 120ms; }
|
||||
.btn:hover { background: var(--navy-900); }
|
||||
.hint { font-size: 12px; color: var(--ink-500); margin-top: 8px; line-height: 1.5; }
|
||||
.footnote { text-align: center; font-size: 12px; color: var(--ink-500); margin-top: 22px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="brand"><img src="../../assets/keysat-mark.svg" alt=""></div>
|
||||
<h1>Keysat admin</h1>
|
||||
<div class="sub">Paste the admin API key from your StartOS service page.</div>
|
||||
<label class="lbl">Admin API key</label>
|
||||
<input class="input" placeholder="ks_admin_…">
|
||||
<div class="hint">Find this in StartOS → Keysat → Properties → adminApiKey.</div>
|
||||
<button class="btn">Sign in</button>
|
||||
<div class="footnote">Connected to <code style="font-family: var(--font-mono); font-size: 11.5px">aurora.keysat.local</code></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
# Keysat — Docs UI Kit
|
||||
|
||||
Developer reference layout. Three-column shell: sidebar (sections), content (markdown), right rail (on-this-page).
|
||||
|
||||
## Files
|
||||
- `index.html` — Integration guide landing page with sidebar, prose, code samples, callouts.
|
||||
|
||||
## Components inline
|
||||
- Docs sidebar — grouped section nav, search.
|
||||
- Prose — h1/h2/h3, lists, callouts, inline code, code blocks.
|
||||
- Right rail — on-this-page jumplinks.
|
||||
@@ -0,0 +1,161 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Keysat Docs — Integration guide</title>
|
||||
<link rel="stylesheet" href="../../colors_and_type.css">
|
||||
<style>
|
||||
*{box-sizing:border-box} html,body{margin:0;padding:0}
|
||||
body{font-family:var(--font-body);color:var(--ink-900);background:var(--cream-100);background-image:radial-gradient(rgba(14,31,51,0.022) 1px,transparent 1px),radial-gradient(rgba(138,111,61,0.020) 1px,transparent 1px);background-size:3px 3px,7px 7px}
|
||||
a{color:var(--navy-800);text-decoration:none}
|
||||
a:hover{text-decoration:underline;text-decoration-thickness:1.5px;text-underline-offset:3px}
|
||||
.topnav{position:sticky;top:0;z-index:10;background:rgba(245,241,232,0.9);backdrop-filter:blur(10px);border-bottom:1px solid var(--border-1);padding:14px 28px;display:flex;align-items:center;gap:18px}
|
||||
.topnav .brand{display:flex;align-items:center;gap:10px;font-family:var(--font-display);font-weight:500;color:var(--navy-900);font-size:14px;letter-spacing:0.28em;text-transform:uppercase}
|
||||
.topnav .brand img{width:26px;height:26px;}
|
||||
.topnav .docs-tag{font-size:11px;font-weight:700;letter-spacing:0.18em;text-transform:uppercase;color:var(--gold-700);padding-left:10px;border-left:1px solid var(--border-2)}
|
||||
.topnav nav{margin-left:auto;display:flex;gap:22px;font-size:13.5px;color:var(--ink-700)}
|
||||
.topnav nav a:hover{color:var(--navy-900)}
|
||||
.search{position:relative;width:240px}
|
||||
.search input{width:100%;padding:7px 10px 7px 30px;font-size:13px;border:1px solid var(--border-1);border-radius:7px;background:var(--cream-50)}
|
||||
.search [data-lucide]{position:absolute;left:9px;top:50%;transform:translateY(-50%);width:14px;height:14px;color:var(--ink-400)}
|
||||
.layout{display:grid;grid-template-columns:240px 1fr 220px;max-width:1280px;margin:0 auto;gap:32px;padding:28px 28px 64px}
|
||||
aside.side{position:sticky;top:74px;align-self:start;font-size:13.5px;max-height:calc(100vh - 90px);overflow:auto;padding-right:8px}
|
||||
aside.side .group{margin-bottom:18px}
|
||||
aside.side .group .glabel{font-size:10.5px;font-weight:700;letter-spacing:0.16em;text-transform:uppercase;color:var(--gold-700);margin:6px 8px 6px}
|
||||
aside.side a{display:block;padding:5px 10px;border-radius:5px;color:var(--ink-700);line-height:1.4}
|
||||
aside.side a:hover{background:var(--cream-200);text-decoration:none}
|
||||
aside.side a.active{background:var(--navy-800);color:var(--cream-50);font-weight:600}
|
||||
main.prose{min-width:0}
|
||||
.prose .crumb{font-size:12px;color:var(--ink-500);margin-bottom:8px;letter-spacing:0.04em}
|
||||
.prose h1{font-family:var(--font-display);font-weight: 500;font-size:38px;letter-spacing: -0.022em;color:var(--navy-950);margin:0 0 8px;line-height:1.1}
|
||||
.prose .lead{font-size:17px;line-height:1.55;color:var(--ink-700);margin:0 0 24px;max-width:640px}
|
||||
.prose h2{font-family:var(--font-display);font-weight:700;font-size:24px;letter-spacing:-0.015em;color:var(--navy-950);margin:36px 0 12px;padding-top:8px;border-top:1px solid var(--border-1);padding-top:24px}
|
||||
.prose h3{font-family:var(--font-display);font-weight:700;font-size:17px;color:var(--navy-950);margin:22px 0 8px;letter-spacing:-0.01em}
|
||||
.prose p{font-size:15px;line-height:1.65;color:var(--ink-700);margin:0 0 14px;max-width:680px}
|
||||
.prose ul{padding-left:22px;margin:0 0 14px;max-width:680px}
|
||||
.prose li{font-size:15px;line-height:1.65;color:var(--ink-700);margin-bottom:4px}
|
||||
.prose code{font-family:var(--font-mono);font-size:13px;background:var(--cream-200);padding:2px 6px;border-radius:4px;color:var(--navy-900)}
|
||||
pre.code{background:var(--navy-950);color:var(--cream-50);padding:18px 22px;border-radius:10px;overflow-x:auto;font-family:var(--font-mono);font-size:13px;line-height:1.7;margin:14px 0 20px;border:1px solid var(--navy-900)}
|
||||
pre.code .c{color:rgba(245,241,232,0.45)} pre.code .k{color:var(--gold-400)} pre.code .s{color:#d4b985} pre.code .f{color:var(--cream-50)}
|
||||
.callout{border:1px solid var(--border-1);border-left:3px solid var(--gold-500);background:var(--cream-50);border-radius:8px;padding:14px 16px;margin:14px 0 22px;display:flex;gap:12px;align-items:flex-start;max-width:680px}
|
||||
.callout [data-lucide]{color:var(--gold-700);width:18px;height:18px;flex-shrink:0;margin-top:2px}
|
||||
.callout p{margin:0;font-size:14px}
|
||||
.callout strong{color:var(--navy-950);font-weight:700}
|
||||
aside.toc{position:sticky;top:74px;align-self:start;font-size:12.5px;border-left:1px solid var(--border-1);padding:8px 0 8px 18px}
|
||||
aside.toc .label{font-size:10.5px;font-weight:700;letter-spacing:0.16em;text-transform:uppercase;color:var(--gold-700);margin-bottom:10px}
|
||||
aside.toc a{display:block;padding:4px 0;color:var(--ink-500);line-height:1.4}
|
||||
aside.toc a:hover{color:var(--navy-900);text-decoration:none}
|
||||
aside.toc a.active{color:var(--navy-900);font-weight:600;border-left:2px solid var(--gold-500);margin-left:-20px;padding-left:18px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topnav">
|
||||
<a href="#" class="brand"><img src="../../assets/keysat-mark.svg" alt=""><span>Keysat</span></a>
|
||||
<span class="docs-tag">Docs</span>
|
||||
<nav>
|
||||
<a href="#">Guide</a>
|
||||
<a href="#">Wire format</a>
|
||||
<a href="#">SDKs</a>
|
||||
<a href="#">Changelog</a>
|
||||
</nav>
|
||||
<div class="search"><i data-lucide="search"></i><input placeholder="Search docs"></div>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
<aside class="side">
|
||||
<div class="group">
|
||||
<div class="glabel">Get started</div>
|
||||
<a href="#">Introduction</a>
|
||||
<a href="#" class="active">Integration guide</a>
|
||||
<a href="#">Quickstart</a>
|
||||
<a href="#">Glossary</a>
|
||||
</div>
|
||||
<div class="group">
|
||||
<div class="glabel">Concepts</div>
|
||||
<a href="#">Products & policies</a>
|
||||
<a href="#">Signing & verification</a>
|
||||
<a href="#">BTCPay webhooks</a>
|
||||
<a href="#">Discounts & comps</a>
|
||||
</div>
|
||||
<div class="group">
|
||||
<div class="glabel">SDKs</div>
|
||||
<a href="#">Rust</a>
|
||||
<a href="#">TypeScript</a>
|
||||
<a href="#">Python</a>
|
||||
<a href="#">Wire format reference</a>
|
||||
</div>
|
||||
<div class="group">
|
||||
<div class="glabel">Operate</div>
|
||||
<a href="#">Backups & recovery</a>
|
||||
<a href="#">Migrating Start9 hardware</a>
|
||||
<a href="#">Troubleshooting</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="prose">
|
||||
<div class="crumb">Get started · Integration guide</div>
|
||||
<h1>Integration guide</h1>
|
||||
<p class="lead">Wire Keysat licenses into your software in under an afternoon. The verifier is pure-function, offline, and ships in five lines.</p>
|
||||
|
||||
<h2 id="prereq">Prerequisites</h2>
|
||||
<p>Before you start, you should have:</p>
|
||||
<ul>
|
||||
<li>A Keysat installation running on your Start9 — see <a href="#">Installation</a>.</li>
|
||||
<li>BTCPay Server connected — see <a href="#">Connect BTCPay</a>.</li>
|
||||
<li>At least one product defined in the admin UI.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="install">Install the SDK</h2>
|
||||
<p>Pick the SDK for your language. All three are wire-compatible — a license issued by your Keysat verifies identically in any of them.</p>
|
||||
<pre class="code"><span class="c"># TypeScript</span>
|
||||
npm install @keysat/licensing-client
|
||||
|
||||
<span class="c"># Rust</span>
|
||||
cargo add licensing-client
|
||||
|
||||
<span class="c"># Python</span>
|
||||
pip install keysat-licensing-client</pre>
|
||||
|
||||
<h2 id="embed">Embed your public key</h2>
|
||||
<p>Copy your issuer public key from <strong>Settings → Issuer key</strong> in the admin UI. Paste it into your application's source code as a compile-time constant.</p>
|
||||
<pre class="code"><span class="k">const</span> <span class="f">ISSUER_PEM</span> = <span class="s">`-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAmz7q8r4t1v…h3k2pXq9wL
|
||||
-----END PUBLIC KEY-----`</span>;</pre>
|
||||
|
||||
<div class="callout">
|
||||
<i data-lucide="info"></i>
|
||||
<p><strong>Embed it. Don't fetch it.</strong> The whole point of offline verification is that your software can't be tricked by a network-level attacker. If you fetch the public key at runtime, you're back to trusting a server.</p>
|
||||
</div>
|
||||
|
||||
<h2 id="verify">Verify a license</h2>
|
||||
<p>Read the user's license key from wherever you store it (a file, the keychain, an env var) and verify it at startup.</p>
|
||||
<pre class="code"><span class="k">import</span> { <span class="f">Verifier</span>, <span class="f">PublicKey</span> } <span class="k">from</span> <span class="s">'@keysat/licensing-client'</span>
|
||||
|
||||
<span class="k">const</span> verifier = <span class="k">new</span> <span class="f">Verifier</span>(<span class="f">PublicKey</span>.<span class="f">fromPem</span>(ISSUER_PEM))
|
||||
<span class="k">const</span> ok = verifier.<span class="f">verify</span>(licenseKeyFromUser)
|
||||
|
||||
<span class="k">if</span> (!ok.valid) <span class="f">exitUnlicensed</span>()
|
||||
<span class="k">if</span> (!ok.entitlements.<span class="f">has</span>(<span class="s">'export'</span>)) <span class="f">disableExport</span>()</pre>
|
||||
|
||||
<h2 id="renewals">Renewals & revocation</h2>
|
||||
<p>Keysat licenses are signed at issue time and do not phone home. If a license is revoked in the admin UI, the existing key continues to verify — that's the trade-off for offline. To support revocation, ship a thin <em>online</em> check that runs on a cadence (e.g. once a week) against your Keysat's public revocation feed.</p>
|
||||
|
||||
<div class="callout">
|
||||
<i data-lucide="key-round"></i>
|
||||
<p><strong>You decide the policy.</strong> Many indie developers don't ship revocation at all — once a key is sold, it stays valid. That's perfectly reasonable.</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside class="toc">
|
||||
<div class="label">On this page</div>
|
||||
<a href="#prereq">Prerequisites</a>
|
||||
<a href="#install">Install the SDK</a>
|
||||
<a href="#embed" class="active">Embed your public key</a>
|
||||
<a href="#verify">Verify a license</a>
|
||||
<a href="#renewals">Renewals & revocation</a>
|
||||
</aside>
|
||||
</div>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script>lucide.createIcons();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
# Keysat — Marketing UI Kit
|
||||
|
||||
A redesign of the single-page marketing site in the Keysat visual system: navy + cream, paper texture, classical type. Same content as the draft (`assets/keysat-draft-site.html`), new visual language.
|
||||
|
||||
## Files
|
||||
|
||||
- `index.html` — the full landing page (single-page marketing site).
|
||||
|
||||
## Sections
|
||||
|
||||
1. **Sticky header** — wordmark + nav + "Install" CTA.
|
||||
2. **Hero** — eyebrow → display headline with a gold-underlined "self-hosted" → lede → CTA row → trust strip ("Runs on Start9 · Pays via BTCPay · Verifies offline").
|
||||
3. **Value props** — 6-card grid; Lucide icons; gold underline on titles.
|
||||
4. **How it works** — numbered 5-step flow with gold serif numerals on cream paper cards.
|
||||
5. **Integration code** — language tabs (Rust / TypeScript / Python) over a navy code block.
|
||||
6. **Sovereign by default** — two-column "what you keep / what's outside the box".
|
||||
7. **Install** — marketplace + sideload, gold-bordered command card.
|
||||
8. **Footer** — navy surface, wordmark, links.
|
||||
|
||||
## Iconography
|
||||
|
||||
Lucide via `unpkg.com/lucide@latest`. Replaces the draft site's emoji 1:1:
|
||||
- ⚡ → `zap`
|
||||
- 🔐 → `key-round`
|
||||
- 📡 → `wifi-off`
|
||||
- 🎫 → `ticket`
|
||||
- 🏷️ → `tag`
|
||||
- 🛠️ → `wrench`
|
||||
@@ -0,0 +1,714 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Keysat — Bitcoin-paid software licensing, self-hosted on Start9</title>
|
||||
<link rel="stylesheet" href="../../colors_and_type.css">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
color: var(--ink-900);
|
||||
background: var(--cream-100);
|
||||
background-image:
|
||||
radial-gradient(rgba(14,31,51,0.025) 1px, transparent 1px),
|
||||
radial-gradient(rgba(138,111,61,0.022) 1px, transparent 1px);
|
||||
background-size: 3px 3px, 7px 7px;
|
||||
background-position: 0 0, 1px 1px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.wrap { max-width: 1180px; margin: 0 auto; padding: 0 32px; }
|
||||
a { color: var(--ink-900); text-decoration: none; }
|
||||
a:hover { color: var(--navy-900); }
|
||||
|
||||
/* ---------- Header ---------- */
|
||||
header.site {
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
background: rgba(245, 241, 232, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--border-1);
|
||||
}
|
||||
header.site .inner {
|
||||
display: flex; align-items: center; gap: 28px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
header.site .brand {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.30em;
|
||||
text-transform: uppercase;
|
||||
color: var(--navy-900);
|
||||
}
|
||||
header.site .brand img { width: 32px; height: 32px; }
|
||||
header.site nav { margin-left: auto; display: flex; gap: 28px; font-size: 14px; }
|
||||
header.site nav a { color: var(--ink-700); font-weight: 500; }
|
||||
header.site nav a:hover { color: var(--navy-900); }
|
||||
header.site .cta { margin-left: 8px; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
font-family: var(--font-body); font-weight: 600; font-size: 14px;
|
||||
padding: 11px 20px; border-radius: 8px; border: 1px solid transparent;
|
||||
cursor: pointer; transition: all 120ms var(--ease-standard); line-height: 1;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn.lg { font-size: 15.5px; padding: 14px 24px; }
|
||||
.btn.primary { background: var(--navy-800); color: var(--cream-50); border-color: var(--navy-800); }
|
||||
.btn.primary:hover { background: var(--navy-900); border-color: var(--navy-900); color: var(--cream-50); }
|
||||
.btn.secondary { background: transparent; color: var(--navy-900); border-color: var(--border-2); }
|
||||
.btn.secondary:hover { background: var(--cream-200); color: var(--navy-900); }
|
||||
.btn.ghost { background: transparent; color: var(--navy-900); border: none; }
|
||||
.btn.ghost:hover { background: rgba(14,31,51,0.06); }
|
||||
.btn .arrow { transition: transform 200ms var(--ease-standard); }
|
||||
.btn:hover .arrow { transform: translateX(2px); }
|
||||
|
||||
/* ---------- Hero ---------- */
|
||||
section.hero { padding: 92px 0 72px; }
|
||||
.hero-grid { display: grid; grid-template-columns: 1.15fr 1fr; gap: 64px; align-items: center; }
|
||||
.hero .eyebrow {
|
||||
font-size: 11.5px; font-weight: 700; letter-spacing: 0.18em;
|
||||
text-transform: uppercase; color: var(--gold-700);
|
||||
display: inline-flex; align-items: center; gap: 10px; margin-bottom: 22px;
|
||||
}
|
||||
.hero .eyebrow::before {
|
||||
content: ''; display: inline-block; width: 28px; height: 1px; background: var(--gold-500);
|
||||
}
|
||||
.hero h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(44px, 5.4vw, 72px);
|
||||
font-weight: 500;
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.022em;
|
||||
color: var(--navy-950);
|
||||
margin: 0 0 22px;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.hero h1 .gold {
|
||||
background-image: linear-gradient(to top, var(--gold-400) 0, var(--gold-400) 6px, transparent 6px);
|
||||
background-repeat: no-repeat;
|
||||
background-position: 0 95%;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.hero p.lede {
|
||||
font-size: 19px;
|
||||
line-height: 1.55;
|
||||
color: var(--ink-700);
|
||||
max-width: 540px;
|
||||
margin: 0 0 32px;
|
||||
}
|
||||
.hero .cta-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||
.hero .trust {
|
||||
margin-top: 36px;
|
||||
display: flex; align-items: center; gap: 18px;
|
||||
font-size: 13px; color: var(--ink-500);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hero .trust span { display: inline-flex; align-items: center; gap: 7px; }
|
||||
.hero .trust .dot { width: 4px; height: 4px; border-radius: 50%; background: var(--gold-500); }
|
||||
.hero .trust [data-lucide] { color: var(--navy-700); }
|
||||
|
||||
/* Hero visual: mocked license certificate */
|
||||
.cert {
|
||||
background: var(--cream-50);
|
||||
border: 1px solid var(--border-1);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 0 0 1px var(--gold-500) inset, 0 8px 16px rgba(14,31,51,0.10), 0 24px 64px rgba(14,31,51,0.10);
|
||||
padding: 36px 36px 30px;
|
||||
position: relative;
|
||||
transform: rotate(-1.2deg);
|
||||
max-width: 460px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.cert::before, .cert::after {
|
||||
content: ''; position: absolute; left: 16px; right: 16px; height: 1px; background: var(--gold-500); opacity: 0.5;
|
||||
}
|
||||
.cert::before { top: 16px; }
|
||||
.cert::after { bottom: 16px; }
|
||||
.cert .seal {
|
||||
position: absolute; right: -30px; top: -30px;
|
||||
width: 88px; height: 88px; border-radius: 50%;
|
||||
background: var(--cream-50);
|
||||
box-shadow: 0 0 0 1px var(--gold-500) inset, 0 0 0 5px var(--cream-50), 0 0 0 6px var(--gold-500), var(--shadow-md);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-family: var(--font-display); font-weight: 900; font-size: 32px; color: var(--navy-800);
|
||||
transform: rotate(8deg);
|
||||
}
|
||||
.cert .stamp {
|
||||
font-size: 9.5px; font-weight: 700; letter-spacing: 0.22em;
|
||||
text-transform: uppercase; color: var(--gold-700);
|
||||
text-align: center; margin-bottom: 14px;
|
||||
}
|
||||
.cert h4 {
|
||||
font-family: var(--font-display); font-weight: 500; font-size: 22px;
|
||||
text-align: center; color: var(--navy-900); margin: 0 0 4px; letter-spacing: -0.015em;
|
||||
}
|
||||
.cert .sub {
|
||||
text-align: center; font-size: 12px; color: var(--ink-500); margin-bottom: 22px;
|
||||
}
|
||||
.cert .field { font-size: 11px; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-500); margin-bottom: 4px; }
|
||||
.cert .value { font-family: var(--font-mono); font-size: 14px; color: var(--navy-900); margin-bottom: 14px; }
|
||||
.cert .row { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||
.cert .sig {
|
||||
border-top: 1px dashed rgba(14,31,51,0.2);
|
||||
padding-top: 12px; margin-top: 6px;
|
||||
font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-500); line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ---------- Section base ---------- */
|
||||
section.block { padding: 96px 0; }
|
||||
section.tinted { background: var(--cream-200); position: relative; }
|
||||
section.tinted::before, section.tinted::after {
|
||||
content: ''; position: absolute; left: 0; right: 0; height: 1px; background: var(--gold-500); opacity: 0.4;
|
||||
}
|
||||
section.tinted::before { top: 0; }
|
||||
section.tinted::after { bottom: 0; }
|
||||
|
||||
.section-head { max-width: 760px; margin-bottom: 56px; }
|
||||
.section-head .eyebrow {
|
||||
font-size: 11.5px; font-weight: 700; letter-spacing: 0.18em;
|
||||
text-transform: uppercase; color: var(--gold-700);
|
||||
margin-bottom: 14px; display: block;
|
||||
}
|
||||
.section-head h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(32px, 3.6vw, 46px);
|
||||
font-weight: 500; line-height: 1.05; letter-spacing: -0.022em;
|
||||
color: var(--navy-950); margin: 0 0 14px;
|
||||
}
|
||||
.section-head p {
|
||||
font-size: 18px; line-height: 1.55; color: var(--ink-700); margin: 0; max-width: 580px;
|
||||
}
|
||||
|
||||
/* ---------- Value grid ---------- */
|
||||
.value-grid {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px;
|
||||
background: var(--border-1);
|
||||
border: 1px solid var(--border-1);
|
||||
border-radius: 14px; overflow: hidden;
|
||||
}
|
||||
.value-grid .item {
|
||||
background: var(--cream-50);
|
||||
padding: 32px 28px;
|
||||
transition: background 150ms;
|
||||
}
|
||||
.value-grid .item:hover { background: var(--cream-100); }
|
||||
.value-grid .icon-wrap {
|
||||
width: 40px; height: 40px; border-radius: 8px;
|
||||
background: var(--cream-200);
|
||||
border: 1px solid var(--border-1);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin-bottom: 18px; color: var(--navy-800);
|
||||
}
|
||||
.value-grid h3 {
|
||||
font-family: var(--font-display); font-weight: 700; font-size: 18px;
|
||||
color: var(--navy-950); margin: 0 0 6px; letter-spacing: -0.01em;
|
||||
}
|
||||
.value-grid h3 + .accent-bar {
|
||||
width: 22px; height: 2px; background: var(--gold-500); margin-bottom: 12px;
|
||||
}
|
||||
.value-grid p { margin: 0; font-size: 14.5px; color: var(--ink-700); line-height: 1.55; }
|
||||
|
||||
/* ---------- Flow ---------- */
|
||||
.flow { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; }
|
||||
.flow .step {
|
||||
background: var(--cream-50);
|
||||
border: 1px solid var(--border-1);
|
||||
border-radius: 12px;
|
||||
padding: 28px 22px 24px;
|
||||
position: relative;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.flow .num {
|
||||
font-family: var(--font-display); font-weight: 900; font-size: 56px;
|
||||
color: var(--gold-500); line-height: 1; margin-bottom: 14px;
|
||||
letter-spacing: -0.04em;
|
||||
font-variant-numeric: lining-nums;
|
||||
}
|
||||
.flow .step h3 {
|
||||
font-family: var(--font-display); font-weight: 700; font-size: 16px;
|
||||
color: var(--navy-950); margin: 0 0 6px; letter-spacing: -0.01em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.flow .step p {
|
||||
font-size: 13.5px; color: var(--ink-700); line-height: 1.5; margin: 0;
|
||||
}
|
||||
|
||||
/* ---------- Code block ---------- */
|
||||
.code-card {
|
||||
background: var(--navy-950);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-lg);
|
||||
border: 1px solid var(--navy-900);
|
||||
}
|
||||
.code-tabs {
|
||||
display: flex;
|
||||
background: var(--navy-900);
|
||||
border-bottom: 1px solid rgba(245,241,232,0.08);
|
||||
padding: 0 8px;
|
||||
}
|
||||
.code-tabs button {
|
||||
background: transparent; border: 0;
|
||||
color: rgba(245,241,232,0.55);
|
||||
font-family: var(--font-body); font-weight: 500; font-size: 13px;
|
||||
padding: 14px 18px; cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 120ms;
|
||||
}
|
||||
.code-tabs button:hover { color: rgba(245,241,232,0.85); }
|
||||
.code-tabs button.active {
|
||||
color: var(--cream-50);
|
||||
border-bottom-color: var(--gold-500);
|
||||
}
|
||||
.code-tabs .install {
|
||||
margin-left: auto;
|
||||
padding: 14px 18px;
|
||||
font-family: var(--font-mono); font-size: 12px;
|
||||
color: var(--gold-400);
|
||||
}
|
||||
pre.code {
|
||||
margin: 0;
|
||||
padding: 24px 28px;
|
||||
font-family: var(--font-mono); font-size: 13.5px;
|
||||
line-height: 1.7; color: var(--cream-50);
|
||||
overflow-x: auto;
|
||||
}
|
||||
pre.code .c { color: rgba(245,241,232,0.45); }
|
||||
pre.code .k { color: var(--gold-400); }
|
||||
pre.code .s { color: #d4b985; }
|
||||
pre.code .n { color: #a6b7cf; }
|
||||
pre.code .f { color: var(--cream-50); }
|
||||
pre.code .p { color: rgba(245,241,232,0.55); }
|
||||
|
||||
.code-section { display: grid; grid-template-columns: 1fr 1fr; gap: 56px; align-items: start; }
|
||||
.code-section .pitch h3 { font-family: var(--font-display); font-weight: 700; font-size: 22px; color: var(--navy-950); margin: 0 0 12px; letter-spacing: -0.015em; }
|
||||
.code-section .pitch p { font-size: 16px; color: var(--ink-700); line-height: 1.55; margin: 0 0 16px; }
|
||||
.code-section .pitch ul { list-style: none; padding: 0; margin: 24px 0 0; }
|
||||
.code-section .pitch li { display: flex; align-items: start; gap: 12px; padding: 8px 0; font-size: 14.5px; color: var(--ink-700); }
|
||||
.code-section .pitch li::before { content: '✓'; color: var(--gold-600); font-weight: 700; flex-shrink: 0; margin-top: 1px; }
|
||||
|
||||
/* ---------- Sovereign panel ---------- */
|
||||
.sov { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
|
||||
.sov .panel {
|
||||
background: var(--cream-50);
|
||||
border: 1px solid var(--border-1);
|
||||
border-radius: 14px;
|
||||
padding: 32px 32px 28px;
|
||||
}
|
||||
.sov .panel.dark {
|
||||
background: var(--navy-950); color: var(--cream-50);
|
||||
border: 1px solid var(--navy-900);
|
||||
}
|
||||
.sov .panel h3 {
|
||||
font-family: var(--font-display); font-weight: 700; font-size: 19px;
|
||||
margin: 0 0 4px; letter-spacing: -0.015em; color: inherit;
|
||||
}
|
||||
.sov .panel .sub { font-size: 13px; color: var(--ink-500); margin-bottom: 22px; }
|
||||
.sov .panel.dark .sub { color: rgba(245,241,232,0.55); }
|
||||
.sov ul { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.sov li {
|
||||
font-size: 13px;
|
||||
padding: 6px 13px; border-radius: 999px;
|
||||
background: var(--cream-100);
|
||||
border: 1px solid var(--border-1);
|
||||
color: var(--ink-700);
|
||||
}
|
||||
.sov .panel.dark li {
|
||||
background: var(--navy-900);
|
||||
border-color: rgba(245,241,232,0.15);
|
||||
color: rgba(245,241,232,0.9);
|
||||
}
|
||||
.sov .panel.dark li.no::before {
|
||||
content: '✕ '; color: rgba(245,241,232,0.45); margin-right: 2px;
|
||||
}
|
||||
.sov .panel .footnote { font-size: 13px; color: var(--ink-500); margin: 18px 0 0; line-height: 1.55; }
|
||||
.sov .panel.dark .footnote { color: rgba(245,241,232,0.6); }
|
||||
|
||||
/* ---------- Install ---------- */
|
||||
.install-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
|
||||
.install-card {
|
||||
background: var(--cream-50); border: 1px solid var(--border-1);
|
||||
border-radius: 14px; padding: 28px;
|
||||
}
|
||||
.install-card.featured { box-shadow: 0 0 0 1px var(--gold-500) inset, var(--shadow-sm); }
|
||||
.install-card .cap {
|
||||
display: inline-block; font-size: 10.5px; font-weight: 700;
|
||||
letter-spacing: 0.18em; text-transform: uppercase; color: var(--gold-700);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.install-card h3 {
|
||||
font-family: var(--font-display); font-weight: 700; font-size: 22px;
|
||||
color: var(--navy-950); margin: 0 0 8px; letter-spacing: -0.015em;
|
||||
}
|
||||
.install-card p { font-size: 14.5px; color: var(--ink-700); margin: 0 0 16px; line-height: 1.55; }
|
||||
.cmd-card {
|
||||
background: var(--navy-950); color: var(--cream-50);
|
||||
border-radius: 10px; padding: 14px 16px;
|
||||
font-family: var(--font-mono); font-size: 13px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.cmd-card .copy {
|
||||
background: rgba(245,241,232,0.10); color: var(--cream-50);
|
||||
border: 0; padding: 6px 10px; border-radius: 6px;
|
||||
font-family: var(--font-body); font-size: 11.5px; cursor: pointer;
|
||||
transition: background 120ms;
|
||||
}
|
||||
.cmd-card .copy:hover { background: rgba(245,241,232,0.20); }
|
||||
.install-card ol { padding-left: 20px; margin: 8px 0 0; color: var(--ink-700); font-size: 14.5px; line-height: 1.7; }
|
||||
.install-card ol code { font-family: var(--font-mono); font-size: 0.9em; padding: 2px 5px; background: var(--cream-200); border-radius: 4px; }
|
||||
|
||||
/* ---------- Footer ---------- */
|
||||
footer.site {
|
||||
background: var(--navy-950); color: var(--cream-300);
|
||||
padding: 56px 0 36px;
|
||||
border-top: 1px solid var(--gold-500);
|
||||
}
|
||||
footer.site .top { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 36px; margin-bottom: 36px; }
|
||||
footer.site .col h5 {
|
||||
font-family: var(--font-body); font-size: 11.5px; font-weight: 700;
|
||||
letter-spacing: 0.18em; text-transform: uppercase; color: var(--gold-400);
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
footer.site .col a {
|
||||
display: block; color: rgba(245,241,232,0.7); padding: 4px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
footer.site .col a:hover { color: var(--cream-50); }
|
||||
footer.site .brand-block { max-width: 320px; }
|
||||
footer.site .brand {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
font-family: var(--font-display); font-weight: 500; font-size: 15px; letter-spacing: 0.30em; text-transform: uppercase;
|
||||
color: var(--cream-50);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
footer.site .brand img { width: 32px; height: 32px; }
|
||||
footer.site .tag { font-size: 13.5px; line-height: 1.55; color: rgba(245,241,232,0.65); margin: 0; }
|
||||
footer.site .bottom {
|
||||
border-top: 1px solid rgba(245,241,232,0.10);
|
||||
padding-top: 24px;
|
||||
display: flex; justify-content: space-between; flex-wrap: wrap; gap: 12px;
|
||||
font-size: 12.5px; color: rgba(245,241,232,0.45);
|
||||
}
|
||||
footer.site .bottom a { color: rgba(245,241,232,0.6); }
|
||||
|
||||
/* ---------- Responsive ---------- */
|
||||
@media (max-width: 980px) {
|
||||
.hero-grid { grid-template-columns: 1fr; gap: 48px; }
|
||||
.cert { transform: none; max-width: 100%; margin: 0 auto; }
|
||||
.value-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.flow { grid-template-columns: repeat(2, 1fr); }
|
||||
.code-section, .sov, .install-grid { grid-template-columns: 1fr; }
|
||||
header.site nav { display: none; }
|
||||
.section-head h2 { font-size: 36px; }
|
||||
section.block { padding: 64px 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="site">
|
||||
<div class="wrap inner">
|
||||
<a class="brand" href="#top">
|
||||
<img src="../../assets/keysat-mark.svg" alt="">
|
||||
<span>Keysat</span>
|
||||
</a>
|
||||
<nav>
|
||||
<a href="#why">Why</a>
|
||||
<a href="#how">How it works</a>
|
||||
<a href="#integrate">Integrate</a>
|
||||
<a href="#sovereign">Sovereign</a>
|
||||
<a href="#install">Install</a>
|
||||
</nav>
|
||||
<a href="#install" class="btn primary cta">Install Keysat</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="hero" id="top">
|
||||
<div class="wrap hero-grid">
|
||||
<div>
|
||||
<div class="eyebrow">Software licensing for Bitcoin creators</div>
|
||||
<h1>Bitcoin-paid software licensing, <span class="gold">self-hosted</span> on Start9.</h1>
|
||||
<p class="lede">
|
||||
Buyers pay in Bitcoin via your own BTCPay. Your software verifies signed keys offline. You own the signing key, the customer list, and the payment rails — no SaaS, no middleman, no platform risk.
|
||||
</p>
|
||||
<div class="cta-row">
|
||||
<a class="btn primary lg" href="#install">Install Keysat <span class="arrow">→</span></a>
|
||||
<a class="btn secondary lg" href="#how">See how it works</a>
|
||||
</div>
|
||||
<div class="trust">
|
||||
<span><i data-lucide="server" style="width:14px;height:14px"></i> Runs on Start9</span>
|
||||
<span class="dot"></span>
|
||||
<span><i data-lucide="bitcoin" style="width:14px;height:14px"></i> Pays via BTCPay</span>
|
||||
<span class="dot"></span>
|
||||
<span><i data-lucide="wifi-off" style="width:14px;height:14px"></i> Verifies offline</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="cert" role="img" aria-label="Sample license certificate">
|
||||
<div class="seal">₿</div>
|
||||
<div class="stamp">— Certificate of License —</div>
|
||||
<h4>Sundial 2.0</h4>
|
||||
<div class="sub">Issued under default policy · single seat · 1 year</div>
|
||||
<div class="field">License key</div>
|
||||
<div class="value">KS-9F2A-7C41-XK22-6D8E</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="field">Issued</div>
|
||||
<div class="value" style="font-size: 13px">2026-04-22</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="field">Expires</div>
|
||||
<div class="value" style="font-size: 13px">2027-04-22</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sig">Ed25519 · mz7q8r4t1v…h3k2pXq9wL · ✓ verified offline</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="block tinted" id="why">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<span class="eyebrow">What this enables</span>
|
||||
<h2>A complete sell-your-software stack, sovereign end-to-end.</h2>
|
||||
<p>Keysat handles the licensing layer. BTCPay handles payments. Your hardware holds the keys. No third party can mint, revoke, or read your sales records.</p>
|
||||
</div>
|
||||
<div class="value-grid">
|
||||
<div class="item">
|
||||
<div class="icon-wrap"><i data-lucide="zap"></i></div>
|
||||
<h3>Bitcoin payments, your store</h3><div class="accent-bar"></div>
|
||||
<p>BTCPay Server on your own Start9 takes the payment. Lightning settles in seconds. Funds go straight to your wallet — no intermediary holds them.</p>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="icon-wrap"><i data-lucide="key-round"></i></div>
|
||||
<h3>You own the signing key</h3><div class="accent-bar"></div>
|
||||
<p>The Ed25519 keypair lives on your hardware. Every license is signed by it. There's no third party who could mint or revoke licenses.</p>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="icon-wrap"><i data-lucide="wifi-off"></i></div>
|
||||
<h3>Offline verification</h3><div class="accent-bar"></div>
|
||||
<p>Your software verifies licenses against an embedded public key. No network call. Customer apps work even if your Keysat goes offline.</p>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="icon-wrap"><i data-lucide="ticket"></i></div>
|
||||
<h3>Trials, expiries, seats, entitlements</h3><div class="accent-bar"></div>
|
||||
<p>Per-product policies for time-limited licenses, multi-seat caps, trial flags, feature entitlements baked into the key.</p>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="icon-wrap"><i data-lucide="tag"></i></div>
|
||||
<h3>Discount & referral codes</h3><div class="accent-bar"></div>
|
||||
<p>Percent-off, fixed-sats-off, or free-license codes (no payment). Run launch promos, comp keys for press, track partner campaigns.</p>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="icon-wrap"><i data-lucide="wrench"></i></div>
|
||||
<h3>SDKs in your language</h3><div class="accent-bar"></div>
|
||||
<p>Rust, TypeScript, Python — wire-compatible offline verifiers. Five lines of code in your app and you're verifying real signatures.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="block" id="how">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<span class="eyebrow">How it works</span>
|
||||
<h2>Five steps, end to end.</h2>
|
||||
<p>From sideload to first sale in an afternoon. No cloud account to create, no API keys to copy.</p>
|
||||
</div>
|
||||
<ol class="flow">
|
||||
<li class="step"><div class="num">01</div><h3>Install on your Start9</h3><p>Sideload the <code>.s9pk</code>, or install from <code>registry.keysat.xyz</code>. BTCPay comes bundled as a dependency.</p></li>
|
||||
<li class="step"><div class="num">02</div><h3>Connect BTCPay</h3><p>One click in the StartOS Actions tab. Authorize once on BTCPay's consent page; Keysat registers a webhook automatically.</p></li>
|
||||
<li class="step"><div class="num">03</div><h3>Define products + policies</h3><p>Declare a product, set its price in sats, define a policy (duration, seat cap, trial, entitlements).</p></li>
|
||||
<li class="step"><div class="num">04</div><h3>Embed your public key</h3><p>Copy your Keysat public key into your app. Add the SDK. Five lines of code verifies a signature at startup.</p></li>
|
||||
<li class="step"><div class="num">05</div><h3>Share your purchase URL</h3><p>Buyers hit your public URL, pay in Bitcoin, get a signed license. Their copy of your software boots up licensed.</p></li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="block tinted" id="integrate">
|
||||
<div class="wrap">
|
||||
<div class="code-section">
|
||||
<div class="pitch">
|
||||
<span class="eyebrow" style="color: var(--gold-700); font-size: 11.5px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase;">For developers</span>
|
||||
<h3 style="margin-top: 14px; font-size: 36px; line-height: 1.1; letter-spacing: -0.022em">Five lines, in the language you already write.</h3>
|
||||
<p>Keysat licenses are Ed25519-signed and base32-encoded. Verification is pure-function — no network, no daemon, no shared state.</p>
|
||||
<ul>
|
||||
<li>Wire-compatible across SDKs</li>
|
||||
<li>Public key embedded at compile time</li>
|
||||
<li>Returns product, policy, expiry, entitlements</li>
|
||||
<li>Source-available, easy to port</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="code-card">
|
||||
<div class="code-tabs">
|
||||
<button class="active" data-lang="ts">TypeScript</button>
|
||||
<button data-lang="rs">Rust</button>
|
||||
<button data-lang="py">Python</button>
|
||||
<span class="install" id="install-cmd">npm install @keysat/licensing-client</span>
|
||||
</div>
|
||||
<pre class="code" id="code-ts"><span class="k">import</span> { <span class="f">Verifier</span>, <span class="f">PublicKey</span> } <span class="k">from</span> <span class="s">'@keysat/licensing-client'</span>
|
||||
|
||||
<span class="k">const</span> verifier = <span class="k">new</span> <span class="f">Verifier</span>(
|
||||
<span class="f">PublicKey</span>.<span class="f">fromPem</span>(ISSUER_PEM)
|
||||
)
|
||||
|
||||
<span class="k">const</span> ok = verifier.<span class="f">verify</span>(licenseKeyFromUser)
|
||||
console.<span class="f">log</span>(<span class="s">'licensed:'</span>, ok.productId, ok.expires)</pre>
|
||||
<pre class="code" id="code-rs" style="display:none"><span class="c">// Cargo.toml</span>
|
||||
<span class="c">// licensing-client = "0.1"</span>
|
||||
|
||||
<span class="k">use</span> licensing_client::{<span class="f">Verifier</span>, <span class="f">PublicKeyPem</span>};
|
||||
|
||||
<span class="k">let</span> pk = <span class="f">PublicKeyPem</span>::from_str(ISSUER_PEM)<span class="p">?</span>;
|
||||
<span class="k">let</span> verifier = <span class="f">Verifier</span>::new(pk);
|
||||
<span class="k">let</span> ok = verifier.verify(&license_key)<span class="p">?</span>;
|
||||
println!(<span class="s">"licensed: {}"</span>, ok.product_id);</pre>
|
||||
<pre class="code" id="code-py" style="display:none"><span class="k">from</span> keysat_licensing_client <span class="k">import</span> Verifier, PublicKey
|
||||
|
||||
verifier = <span class="f">Verifier</span>(<span class="f">PublicKey</span>.<span class="f">from_pem</span>(ISSUER_PEM))
|
||||
ok = verifier.<span class="f">verify</span>(license_key_from_user)
|
||||
|
||||
<span class="k">print</span>(<span class="s">"licensed for"</span>, ok.product_id)</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="block" id="sovereign">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<span class="eyebrow">Sovereign by default</span>
|
||||
<h2>Everything stays on your hardware.</h2>
|
||||
<p>If you migrate Start9 boxes, all of Keysat goes with you. If Keysat the project disappears, your existing licenses keep verifying — the public key is embedded in your software, the private key is on your machine.</p>
|
||||
</div>
|
||||
<div class="sov">
|
||||
<div class="panel">
|
||||
<h3>What you keep</h3>
|
||||
<div class="sub">On your Start9, in your normal backups.</div>
|
||||
<ul>
|
||||
<li>Signing keypair</li>
|
||||
<li>Customer email · npub list</li>
|
||||
<li>Sale records</li>
|
||||
<li>Audit log</li>
|
||||
<li>BTCPay invoice history</li>
|
||||
<li>Webhook subscribers</li>
|
||||
<li>Bitcoin (your wallet)</li>
|
||||
</ul>
|
||||
<p class="footnote">Backed up automatically by StartOS as part of your normal backup routine.</p>
|
||||
</div>
|
||||
<div class="panel dark">
|
||||
<h3>What's outside the box</h3>
|
||||
<div class="sub">Things you don't have to deal with.</div>
|
||||
<ul>
|
||||
<li class="no">Stripe</li>
|
||||
<li class="no">Gumroad</li>
|
||||
<li class="no">Paddle</li>
|
||||
<li class="no">Cryptlex</li>
|
||||
<li class="no">Keygen</li>
|
||||
<li class="no">LicenseSpring</li>
|
||||
<li class="no">SaaS subscription fees</li>
|
||||
<li class="no">Platform decisions about who you sell to</li>
|
||||
</ul>
|
||||
<p class="footnote">Source-available license · one-time payment in sats · ships with you when you migrate hardware.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="block tinted" id="install">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<span class="eyebrow">Install</span>
|
||||
<h2>From the marketplace, or sideload directly.</h2>
|
||||
</div>
|
||||
<div class="install-grid">
|
||||
<div class="install-card featured">
|
||||
<span class="cap">Recommended</span>
|
||||
<h3>From the marketplace</h3>
|
||||
<p>Add the Keysat marketplace to your Start9, then click Install.</p>
|
||||
<div class="cmd-card">
|
||||
<span>https://registry.keysat.xyz</span>
|
||||
<button class="copy">Copy</button>
|
||||
</div>
|
||||
<p style="margin-top: 16px; font-size: 13.5px; color: var(--ink-500)">StartOS dashboard → Marketplace → Add → paste the URL.</p>
|
||||
</div>
|
||||
<div class="install-card">
|
||||
<span class="cap">Alternative</span>
|
||||
<h3>Sideload</h3>
|
||||
<p>If you'd rather not add the marketplace:</p>
|
||||
<ol>
|
||||
<li>Download <code>keysat_x86_64.s9pk</code> from <a href="#">GitHub releases</a>.</li>
|
||||
<li>StartOS dashboard → Sideload → drag the file in.</li>
|
||||
<li>Click Install.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="site">
|
||||
<div class="wrap">
|
||||
<div class="top">
|
||||
<div class="brand-block">
|
||||
<div class="brand"><img src="../../assets/keysat-mark.svg" alt=""><span>Keysat</span></div>
|
||||
<p class="tag">Software licensing for Bitcoin creators. Self-hosted, sovereign, source-available.</p>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h5>Product</h5>
|
||||
<a href="#why">Why Keysat</a>
|
||||
<a href="#how">How it works</a>
|
||||
<a href="#install">Install</a>
|
||||
<a href="#">Marketplace</a>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h5>Developers</h5>
|
||||
<a href="#integrate">Integration</a>
|
||||
<a href="#">SDKs</a>
|
||||
<a href="#">Wire format</a>
|
||||
<a href="#">GitHub</a>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h5>Contact</h5>
|
||||
<a href="mailto:licensing@keysat.xyz">licensing@keysat.xyz</a>
|
||||
<a href="#">Status</a>
|
||||
<a href="#">Changelog</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bottom">
|
||||
<span>© Keysat. Source-available; not open-source.</span>
|
||||
<span>Runs on Start9 · Pays via BTCPay · Verifies offline</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
|
||||
// Tab switching for code samples
|
||||
const installCmds = {
|
||||
ts: 'npm install @keysat/licensing-client',
|
||||
rs: 'cargo add licensing-client',
|
||||
py: 'pip install keysat-licensing-client',
|
||||
};
|
||||
document.querySelectorAll('.code-tabs button').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const lang = btn.dataset.lang;
|
||||
document.querySelectorAll('.code-tabs button').forEach(b => b.classList.toggle('active', b === btn));
|
||||
['ts','rs','py'].forEach(l => {
|
||||
document.getElementById('code-' + l).style.display = l === lang ? 'block' : 'none';
|
||||
});
|
||||
document.getElementById('install-cmd').textContent = installCmds[lang];
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,506 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Keysat — Bitcoin-paid software licensing, self-hosted on Start9</title>
|
||||
<meta name="description" content="Keysat is a self-hosted, Bitcoin-paid software licensing server. Sell licenses to your own software using BTCPay Server on your own Start9. You own the keys, the customer list, and the payment rails.">
|
||||
|
||||
<meta property="og:title" content="Keysat — Bitcoin-paid software licensing">
|
||||
<meta property="og:description" content="Self-hosted, Bitcoin-paid software licensing. Run on your own Start9. You own the signing key, the customer records, and the payment rails.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://keysat.xyz">
|
||||
<meta property="og:image" content="https://keysat.xyz/assets/keysat-thumbnail.png">
|
||||
|
||||
<link rel="icon" type="image/png" href="/assets/icon.png">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--accent: #f59e0b; /* amber, matches Bitcoin orange family */
|
||||
--accent-strong: #d97706;
|
||||
--bg: #0d0f14;
|
||||
--bg-card: #14171f;
|
||||
--bg-elev: #1c2029;
|
||||
--fg: #e8eaf0;
|
||||
--fg-strong: #ffffff;
|
||||
--muted: #9aa0ab;
|
||||
--border: #2a2f3a;
|
||||
--code-bg: #1c2029;
|
||||
--max-w: 64rem;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg: #fafaf7;
|
||||
--bg-card: #ffffff;
|
||||
--bg-elev: #f4f3ee;
|
||||
--fg: #1a1d23;
|
||||
--fg-strong: #000000;
|
||||
--muted: #58606e;
|
||||
--border: #e3e1da;
|
||||
--code-bg: #f0eee6;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Inter", Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { color: var(--accent-strong); text-decoration: underline; }
|
||||
|
||||
/* ---------- header ---------- */
|
||||
header.site {
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
background: rgba(13,15,20,0.85);
|
||||
backdrop-filter: saturate(180%) blur(8px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
header.site { background: rgba(250,250,247,0.85); }
|
||||
}
|
||||
header.site .inner {
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
padding: 0.75rem 1.5rem;
|
||||
display: flex; align-items: center; gap: 1rem;
|
||||
}
|
||||
header.site .brand {
|
||||
display: flex; align-items: center; gap: 0.6rem;
|
||||
font-weight: 700; color: var(--fg-strong); text-decoration: none;
|
||||
}
|
||||
header.site .brand img { width: 32px; height: 32px; }
|
||||
header.site nav { margin-left: auto; display: flex; gap: 1.25rem; font-size: 0.92rem; }
|
||||
header.site nav a { color: var(--muted); }
|
||||
header.site nav a:hover { color: var(--fg-strong); text-decoration: none; }
|
||||
|
||||
/* ---------- hero ---------- */
|
||||
section.hero { padding: 6rem 1.5rem 4rem; text-align: center; }
|
||||
section.hero .inner { max-width: var(--max-w); margin: 0 auto; }
|
||||
section.hero img.logo {
|
||||
width: 96px; height: 96px;
|
||||
margin-bottom: 1.5rem;
|
||||
filter: drop-shadow(0 6px 24px rgba(245,158,11,0.25));
|
||||
}
|
||||
section.hero h1 {
|
||||
font-size: clamp(2rem, 5vw, 3.25rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
margin: 0 0 1rem;
|
||||
color: var(--fg-strong);
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
section.hero h1 .accent { color: var(--accent); }
|
||||
section.hero p.lede {
|
||||
font-size: clamp(1.05rem, 2.2vw, 1.35rem);
|
||||
color: var(--muted);
|
||||
max-width: 38rem;
|
||||
margin: 0 auto 2rem;
|
||||
}
|
||||
.cta-row { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; }
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.15s;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.btn:hover { text-decoration: none; }
|
||||
.btn.primary { background: var(--accent); color: #000; border-color: var(--accent); }
|
||||
.btn.primary:hover { background: var(--accent-strong); border-color: var(--accent-strong); }
|
||||
.btn.ghost { background: transparent; color: var(--fg-strong); border-color: var(--border); }
|
||||
.btn.ghost:hover { background: var(--bg-elev); border-color: var(--muted); color: var(--fg-strong); }
|
||||
|
||||
/* ---------- generic section ---------- */
|
||||
section.block { padding: 4rem 1.5rem; }
|
||||
section.block .inner { max-width: var(--max-w); margin: 0 auto; }
|
||||
section.block h2 {
|
||||
font-size: clamp(1.5rem, 3vw, 2.1rem);
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--fg-strong);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
section.block .subtitle {
|
||||
color: var(--muted);
|
||||
max-width: 36rem;
|
||||
margin: 0 0 2.5rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
section.alt { background: var(--bg-card); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
||||
|
||||
/* ---------- value-prop grid ---------- */
|
||||
.value-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.value-grid .item {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
padding: 1.25rem 1.4rem;
|
||||
}
|
||||
.value-grid .item h3 {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: 1rem;
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.value-grid .item .icon {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
display: block;
|
||||
}
|
||||
.value-grid .item p {
|
||||
margin: 0; color: var(--muted); font-size: 0.92rem; line-height: 1.55;
|
||||
}
|
||||
|
||||
/* ---------- how it works ---------- */
|
||||
ol.flow {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
ol.flow li {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
padding: 1.25rem;
|
||||
position: relative;
|
||||
counter-increment: step;
|
||||
}
|
||||
ol.flow li::before {
|
||||
content: counter(step);
|
||||
position: absolute;
|
||||
top: -0.65rem; left: 1.25rem;
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
width: 1.7rem; height: 1.7rem;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: 0.95rem;
|
||||
}
|
||||
ol.flow { counter-reset: step; }
|
||||
ol.flow li h3 {
|
||||
margin: 0.5rem 0 0.4rem;
|
||||
color: var(--fg-strong);
|
||||
font-size: 1rem;
|
||||
}
|
||||
ol.flow li p {
|
||||
margin: 0; color: var(--muted); font-size: 0.92rem; line-height: 1.55;
|
||||
}
|
||||
|
||||
/* ---------- code block ---------- */
|
||||
pre.code {
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem 1.25rem;
|
||||
overflow-x: auto;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
margin: 0.75rem 0 1.25rem;
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
||||
}
|
||||
code.inline {
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.1em 0.4em;
|
||||
border-radius: 0.25rem;
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
/* ---------- two-column ---------- */
|
||||
.two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.two-col { grid-template-columns: 1fr; gap: 1.25rem; }
|
||||
}
|
||||
.two-col h3 { color: var(--fg-strong); margin-top: 0; }
|
||||
|
||||
/* ---------- footer ---------- */
|
||||
footer.site {
|
||||
background: var(--bg-card);
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 2.5rem 1.5rem;
|
||||
}
|
||||
footer.site .inner {
|
||||
max-width: var(--max-w); margin: 0 auto;
|
||||
display: flex; gap: 2rem; flex-wrap: wrap; justify-content: space-between;
|
||||
color: var(--muted); font-size: 0.9rem;
|
||||
}
|
||||
footer.site .brand {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
font-weight: 600; color: var(--fg-strong);
|
||||
}
|
||||
footer.site .brand img { width: 24px; height: 24px; }
|
||||
footer.site .links {
|
||||
display: flex; gap: 1.5rem; flex-wrap: wrap;
|
||||
}
|
||||
footer.site .links a { color: var(--muted); }
|
||||
footer.site .links a:hover { color: var(--fg-strong); }
|
||||
|
||||
/* ---------- pill list ---------- */
|
||||
ul.pillets { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
ul.pillets li {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elev);
|
||||
color: var(--fg);
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="site">
|
||||
<div class="inner">
|
||||
<a class="brand" href="#top"><img src="/assets/icon.png" alt=""><span>Keysat</span></a>
|
||||
<nav>
|
||||
<a href="#why">Why</a>
|
||||
<a href="#how">How it works</a>
|
||||
<a href="#integrate">For developers</a>
|
||||
<a href="#install">Install</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="hero" id="top">
|
||||
<div class="inner">
|
||||
<img src="/assets/icon.png" alt="" class="logo">
|
||||
<h1>Bitcoin-paid software licensing,<br><span class="accent">self-hosted</span> on Start9.</h1>
|
||||
<p class="lede">
|
||||
Keysat is the licensing server you run on your own Start9. Buyers pay in Bitcoin via your own BTCPay. Your software verifies signed keys offline. You own the signing key, the customer list, and the payment rails — no SaaS, no middleman, no platform risk.
|
||||
</p>
|
||||
<div class="cta-row">
|
||||
<a class="btn primary" href="#install">Install Keysat</a>
|
||||
<a class="btn ghost" href="#how">How it works</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="block alt" id="why">
|
||||
<div class="inner">
|
||||
<h2>What this enables</h2>
|
||||
<p class="subtitle">A complete sell-your-software stack, sovereign end-to-end.</p>
|
||||
<div class="value-grid">
|
||||
<div class="item">
|
||||
<span class="icon">⚡</span>
|
||||
<h3>Bitcoin payments, your store</h3>
|
||||
<p>BTCPay Server on your own Start9 takes the payment. Lightning settles in seconds. Funds go straight to your wallet — no intermediary holds them.</p>
|
||||
</div>
|
||||
<div class="item">
|
||||
<span class="icon">🔐</span>
|
||||
<h3>You own the signing key</h3>
|
||||
<p>The Ed25519 keypair lives on your hardware. Every license you issue is signed by it. There's no third party who could mint or revoke licenses.</p>
|
||||
</div>
|
||||
<div class="item">
|
||||
<span class="icon">📡</span>
|
||||
<h3>Offline verification</h3>
|
||||
<p>Your software verifies licenses against an embedded public key. No network call. Your customers' apps work even if your Keysat goes offline.</p>
|
||||
</div>
|
||||
<div class="item">
|
||||
<span class="icon">🎫</span>
|
||||
<h3>Trials, expiries, seats, entitlements</h3>
|
||||
<p>Per-product policies for time-limited licenses, multi-seat caps, trial flags, feature entitlements baked into the key.</p>
|
||||
</div>
|
||||
<div class="item">
|
||||
<span class="icon">🏷️</span>
|
||||
<h3>Discount & referral codes</h3>
|
||||
<p>Percent-off, fixed-sats-off, or free-license codes (no payment). Run launch promos, comp keys for press, track partner campaigns.</p>
|
||||
</div>
|
||||
<div class="item">
|
||||
<span class="icon">🛠️</span>
|
||||
<h3>SDKs in your language</h3>
|
||||
<p>Rust, TypeScript, Python — wire-compatible offline verifiers. Five lines of code in your app and you're verifying real signatures.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="block" id="how">
|
||||
<div class="inner">
|
||||
<h2>How it works</h2>
|
||||
<p class="subtitle">Five steps, end to end.</p>
|
||||
<ol class="flow">
|
||||
<li>
|
||||
<h3>Install Keysat on your Start9</h3>
|
||||
<p>Sideload the <code class="inline">.s9pk</code>, or install from <a href="https://registry.keysat.xyz">registry.keysat.xyz</a>. Keysat declares BTCPay Server as a dependency, so you'll have BTCPay running too.</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>Connect BTCPay in one click</h3>
|
||||
<p>Click "Connect BTCPay" in the StartOS Actions tab. You authorize once on your BTCPay's consent page; Keysat auto-detects your store and registers a webhook. No API keys to copy.</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>Define your products + policies</h3>
|
||||
<p>In the Keysat web UI: declare your product, set its price in sats, define a policy (duration, seat cap, trial flag, entitlements). The policy slugged <code class="inline">default</code> drives your public purchase flow.</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>Embed your public key in your software</h3>
|
||||
<p>Copy your Keysat public key into your app's source. Add the SDK (<code class="inline">pip install</code>, <code class="inline">cargo add</code>, <code class="inline">npm install</code>). Five lines of integration code verifies a license at startup.</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>Share your purchase URL</h3>
|
||||
<p>Your buyers hit your public Keysat URL, pay in Bitcoin, get a signed license key delivered. Their copy of your software boots up licensed. You see the sale in your audit log.</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="block alt" id="integrate">
|
||||
<div class="inner">
|
||||
<h2>Wiring it into your app</h2>
|
||||
<p class="subtitle">A working offline check is five lines.</p>
|
||||
|
||||
<div class="two-col">
|
||||
<div>
|
||||
<h3>Python</h3>
|
||||
<pre class="code">pip install keysat-licensing-client
|
||||
|
||||
from keysat_licensing_client import Verifier, PublicKey
|
||||
|
||||
verifier = Verifier(PublicKey.from_pem(ISSUER_PEM))
|
||||
ok = verifier.verify(license_key_from_user)
|
||||
print("licensed for", ok.product_id)</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Rust</h3>
|
||||
<pre class="code">[dependencies]
|
||||
licensing-client = "0.1"
|
||||
|
||||
use licensing_client::{Verifier, PublicKeyPem};
|
||||
let pk = PublicKeyPem::from_str(ISSUER_PEM)?;
|
||||
let verifier = Verifier::new(pk);
|
||||
let ok = verifier.verify(&license_key)?;
|
||||
println!("licensed: {}", ok.product_id);</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h3>TypeScript / JavaScript</h3>
|
||||
<pre class="code">npm install @keysat/licensing-client
|
||||
|
||||
import { Verifier, PublicKey } from '@keysat/licensing-client'
|
||||
|
||||
const verifier = new Verifier(PublicKey.fromPem(ISSUER_PEM))
|
||||
const ok = verifier.verify(licenseKeyFromUser)
|
||||
console.log('licensed:', ok.productId)</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Other languages</h3>
|
||||
<p>Any language with Ed25519 + base32 (Go, Java, Swift, C#, C++, …) can verify Keysat keys. The wire format is fully documented; thin SDKs can be ported in a few hours. Go and Java/Swift SDKs are on the roadmap.</p>
|
||||
<ul class="pillets">
|
||||
<li>Go (planned)</li>
|
||||
<li>Java/Kotlin (planned)</li>
|
||||
<li>Swift (planned)</li>
|
||||
<li>C#/.NET (planned)</li>
|
||||
<li>C++ (planned)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="block" id="install">
|
||||
<div class="inner">
|
||||
<h2>Install</h2>
|
||||
<p class="subtitle">From the marketplace, or sideload directly.</p>
|
||||
|
||||
<div class="two-col">
|
||||
<div>
|
||||
<h3>From the marketplace</h3>
|
||||
<p>Add the Keysat marketplace to your Start9:</p>
|
||||
<pre class="code">https://registry.keysat.xyz</pre>
|
||||
<p>StartOS dashboard → Marketplace → Add → paste the URL above. Keysat will appear; click Install.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Sideload</h3>
|
||||
<p>If you'd rather not add the marketplace:</p>
|
||||
<ol style="margin: 0.5rem 0 0; padding-left: 1.25rem; color: var(--muted)">
|
||||
<li>Download the latest <code class="inline">keysat_x86_64.s9pk</code> from <a href="https://github.com/keysat-xyz/keysat-startos/releases">GitHub releases</a>.</li>
|
||||
<li>StartOS dashboard → Sideload → drag the file in.</li>
|
||||
<li>Click Install.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top: 2.5rem">Then once installed</h3>
|
||||
<ol class="flow" style="margin-top: 1rem">
|
||||
<li>
|
||||
<h3>Run "Connect BTCPay"</h3>
|
||||
<p>One click; Keysat handles the rest.</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>Set your operator name</h3>
|
||||
<p>What buyers see on receipts and the public homepage.</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>Open the admin web UI</h3>
|
||||
<p>Click "Launch UI" on the Keysat service in StartOS. Paste your admin API key. Create your first product, policy, and discount code from there.</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="block alt">
|
||||
<div class="inner">
|
||||
<h2>Sovereign by default</h2>
|
||||
<div class="two-col">
|
||||
<div>
|
||||
<h3>What you keep</h3>
|
||||
<ul class="pillets">
|
||||
<li>Signing keypair</li>
|
||||
<li>Customer email / npub list</li>
|
||||
<li>Sale records</li>
|
||||
<li>Audit log</li>
|
||||
<li>BTCPay invoice history</li>
|
||||
<li>Webhook subscribers</li>
|
||||
<li>Bitcoin (your wallet)</li>
|
||||
</ul>
|
||||
<p style="margin-top:1rem;color:var(--muted)">Backed up automatically by StartOS as part of your normal backup routine.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>What's outside the box</h3>
|
||||
<ul class="pillets">
|
||||
<li>No Stripe</li>
|
||||
<li>No Gumroad</li>
|
||||
<li>No Paddle</li>
|
||||
<li>No Cryptlex / Keygen / LicenseSpring</li>
|
||||
<li>No SaaS subscription fees</li>
|
||||
<li>No platform decisions about who you can sell to</li>
|
||||
</ul>
|
||||
<p style="margin-top:1rem;color:var(--muted)">Source-available license; one-time payment in sats; everything ships with you when you migrate Start9 hardware.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="site">
|
||||
<div class="inner">
|
||||
<a href="#top" class="brand"><img src="/assets/icon.png" alt=""><span>Keysat</span></a>
|
||||
<div class="links">
|
||||
<a href="https://registry.keysat.xyz">Marketplace</a>
|
||||
<a href="https://github.com/keysat-xyz/keysat">Source</a>
|
||||
<a href="https://github.com/keysat-xyz/keysat/blob/main/docs/INTEGRATION.md">Integration docs</a>
|
||||
<a href="mailto:licensing@keysat.xyz">Contact</a>
|
||||
</div>
|
||||
<div>© Keysat. Source-available; not open-source.</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 1.6 MiB |
@@ -0,0 +1,70 @@
|
||||
# Format cross-check
|
||||
|
||||
Verifies that the three implementations of the `LIC1-...-...` key format
|
||||
(licensing-service in Rust, licensing-client-rust SDK, licensing-client-ts SDK)
|
||||
all agree, byte for byte, on **both** the legacy v1 payload layout and the
|
||||
current v2 layout.
|
||||
|
||||
## Approach
|
||||
|
||||
1. `reference_signer.py` builds three license keys from scratch using the exact
|
||||
byte layouts documented in `licensing-service/src/crypto/mod.rs`. It uses
|
||||
Python's `cryptography` for Ed25519 signing — an independent implementation
|
||||
from the Rust one, so agreement here is strong evidence the format is
|
||||
correct, not just that the Rust side agrees with itself.
|
||||
|
||||
The three keys exercise every branch of the parser:
|
||||
|
||||
- **`v1`** — legacy fixed-74 payload, fingerprint-bound. New keys aren't
|
||||
issued in v1 anymore, but SDKs must still accept them indefinitely so
|
||||
old keys in the wild keep working.
|
||||
- **`v2`** — trial key, fingerprint-bound, with explicit expiry and two
|
||||
entitlements. Stresses the variable-length tail parser.
|
||||
- **`v2_perpetual_unbound`** — the "happy path" for a normal paid purchase:
|
||||
v2, no expiry, no fingerprint binding, no entitlements.
|
||||
|
||||
2. `run_ts.mjs` imports the built TypeScript SDK (`dist/index.js`) and runs
|
||||
each of the three fixtures through: field-by-field parse, signature
|
||||
verification, fingerprint binding (positive + negative), entitlement
|
||||
lookup, `isExpiredAt` boundaries, and tamper detection. Also spot-checks
|
||||
`hashFingerprint` against Python's `hashlib.sha256` and public-key loading.
|
||||
|
||||
## Rust SDK coverage
|
||||
|
||||
The Rust SDK uses the same crates as the service (`data_encoding::BASE32_NOPAD`,
|
||||
`ed25519_dalek`) with identical byte offsets (`licensing-client-rust/src/key.rs`
|
||||
mirrors `licensing-service/src/crypto/mod.rs`), so round-trip compatibility is
|
||||
guaranteed by construction. If you change the layout, update the service's
|
||||
`from_bytes`, the Rust SDK's `from_bytes`, and `reference_signer.py` together.
|
||||
|
||||
The Rust SDK's own unit tests (`cargo test -p licensing-client`) round-trip
|
||||
every flag + version combination.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# Build the TS SDK first.
|
||||
cd licensing-client-ts
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
cd ../tests/crosscheck
|
||||
python3 reference_signer.py > vector.json
|
||||
node --experimental-vm-modules run_ts.mjs
|
||||
```
|
||||
|
||||
## Current test vectors
|
||||
|
||||
Both vectors share a deterministic signing key seeded with `bytes(range(32))`,
|
||||
so the license strings in `vector.json` are stable across regenerations (a
|
||||
regression in encoding will produce a git diff).
|
||||
|
||||
| fixture | version | expires_at | entitlements | flags |
|
||||
|------------------------|---------|-------------|------------------|-----------------------------------------|
|
||||
| `v1` | 1 | (n/a) | (n/a) | `FINGERPRINT_BOUND` |
|
||||
| `v2` | 2 | 1900000000 | `["pro","multi-device"]` | `FINGERPRINT_BOUND \| TRIAL` |
|
||||
| `v2_perpetual_unbound` | 2 | 0 | `[]` | `0` |
|
||||
|
||||
Product UUID: `6f46a4f6-0d2f-4a28-b6aa-3e8fbf6f28f0`, fingerprint raw string
|
||||
`"test-machine-fingerprint"` (SHA-256
|
||||
`d34461ff63170de633b5aa2512ce2e15ac120b1c325acdada67c02e594ba3b3d`).
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Produce LIC1-...-... keys the service would accept — used to verify the
|
||||
SDK parsers round-trip correctly.
|
||||
|
||||
Emits two fixtures now: v1 (legacy fixed-74 payload) and v2 (variable-length
|
||||
payload with expires_at + entitlements). SDKs must accept both.
|
||||
|
||||
Byte layout (matches licensing-service/src/crypto/mod.rs exactly):
|
||||
|
||||
v1 (74 bytes, fixed):
|
||||
[0] version = 1
|
||||
[1] flags
|
||||
[2..18] product_id (UUID BE, 16 bytes)
|
||||
[18..34] license_id (UUID BE, 16 bytes)
|
||||
[34..42] issued_at (u64 BE, unix seconds)
|
||||
[42..74] fingerprint_hash (SHA-256, zero if unbound)
|
||||
|
||||
v2 (83 bytes + variable entitlements):
|
||||
[0] version = 2
|
||||
[1] flags
|
||||
[2..18] product_id
|
||||
[18..34] license_id
|
||||
[34..42] issued_at (u64 BE)
|
||||
[42..50] expires_at (u64 BE, 0 = perpetual)
|
||||
[50..82] fingerprint_hash (SHA-256, zero if unbound)
|
||||
[82] num_entitlements (u8)
|
||||
[83..] for each: [u8 len][len bytes of UTF-8 slug]
|
||||
|
||||
Signature: 64 bytes over the raw payload bytes.
|
||||
Encoding: LIC1-<base32(payload)>-<base32(signature)>
|
||||
RFC 4648 base32, uppercase, no padding.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
Encoding, PrivateFormat, PublicFormat, NoEncryption,
|
||||
)
|
||||
|
||||
KEY_VERSION_V1 = 1
|
||||
KEY_VERSION_V2 = 2
|
||||
|
||||
FLAG_FINGERPRINT_BOUND = 0b0000_0001
|
||||
FLAG_TRIAL = 0b0000_0010
|
||||
|
||||
|
||||
def b32nopad(b: bytes) -> str:
|
||||
return base64.b32encode(b).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def make_payload_v1(
|
||||
flags: int,
|
||||
product_id: bytes, license_id: bytes,
|
||||
issued_at: int, fp_hash: bytes,
|
||||
) -> bytes:
|
||||
assert len(product_id) == 16
|
||||
assert len(license_id) == 16
|
||||
assert len(fp_hash) == 32
|
||||
payload = (
|
||||
bytes([KEY_VERSION_V1, flags])
|
||||
+ product_id
|
||||
+ license_id
|
||||
+ issued_at.to_bytes(8, "big")
|
||||
+ fp_hash
|
||||
)
|
||||
assert len(payload) == 74, f"v1 payload is {len(payload)} bytes, expected 74"
|
||||
return payload
|
||||
|
||||
|
||||
def make_payload_v2(
|
||||
flags: int,
|
||||
product_id: bytes, license_id: bytes,
|
||||
issued_at: int, expires_at: int,
|
||||
fp_hash: bytes, entitlements: list[str],
|
||||
) -> bytes:
|
||||
assert len(product_id) == 16
|
||||
assert len(license_id) == 16
|
||||
assert len(fp_hash) == 32
|
||||
assert 0 <= len(entitlements) <= 255
|
||||
tail = bytearray()
|
||||
for slug in entitlements:
|
||||
encoded = slug.encode("utf-8")
|
||||
assert len(encoded) <= 255, f"entitlement '{slug}' too long"
|
||||
tail.append(len(encoded))
|
||||
tail.extend(encoded)
|
||||
head = (
|
||||
bytes([KEY_VERSION_V2, flags])
|
||||
+ product_id
|
||||
+ license_id
|
||||
+ issued_at.to_bytes(8, "big")
|
||||
+ expires_at.to_bytes(8, "big")
|
||||
+ fp_hash
|
||||
+ bytes([len(entitlements)])
|
||||
)
|
||||
assert len(head) == 83
|
||||
return head + bytes(tail)
|
||||
|
||||
|
||||
def sign_and_encode(sk: Ed25519PrivateKey, payload: bytes) -> str:
|
||||
sig = sk.sign(payload)
|
||||
assert len(sig) == 64
|
||||
return f"LIC1-{b32nopad(payload)}-{b32nopad(sig)}"
|
||||
|
||||
|
||||
def main():
|
||||
# Deterministic test vector — fixed seeds so the output is stable.
|
||||
sk = Ed25519PrivateKey.from_private_bytes(bytes(range(32)))
|
||||
pk = sk.public_key()
|
||||
pub_pem = pk.public_bytes(
|
||||
Encoding.PEM, PublicFormat.SubjectPublicKeyInfo
|
||||
).decode()
|
||||
|
||||
product_id = uuid.UUID("6f46a4f6-0d2f-4a28-b6aa-3e8fbf6f28f0").bytes
|
||||
license_id_v1 = uuid.UUID("11111111-2222-3333-4444-555555555555").bytes
|
||||
license_id_v2 = uuid.UUID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee").bytes
|
||||
issued_at = 1_700_000_000
|
||||
expires_at_v2 = 1_900_000_000 # ~2030
|
||||
fingerprint_raw = "test-machine-fingerprint"
|
||||
fp_hash = hashlib.sha256(fingerprint_raw.encode()).digest()
|
||||
entitlements_v2 = ["pro", "multi-device"]
|
||||
|
||||
# v1: legacy fingerprint-bound key.
|
||||
v1_payload = make_payload_v1(
|
||||
FLAG_FINGERPRINT_BOUND,
|
||||
product_id, license_id_v1, issued_at, fp_hash,
|
||||
)
|
||||
v1_key = sign_and_encode(sk, v1_payload)
|
||||
|
||||
# v2: trial + fingerprint-bound, with entitlements and expiry.
|
||||
v2_flags = FLAG_FINGERPRINT_BOUND | FLAG_TRIAL
|
||||
v2_payload = make_payload_v2(
|
||||
v2_flags,
|
||||
product_id, license_id_v2, issued_at, expires_at_v2,
|
||||
fp_hash, entitlements_v2,
|
||||
)
|
||||
v2_key = sign_and_encode(sk, v2_payload)
|
||||
|
||||
# v2 perpetual, unbound, no entitlements — the "happy path" for a normal
|
||||
# permanent license purchase.
|
||||
v2_plain_payload = make_payload_v2(
|
||||
0,
|
||||
product_id, license_id_v2, issued_at, 0,
|
||||
bytes(32), [],
|
||||
)
|
||||
v2_plain_key = sign_and_encode(sk, v2_plain_payload)
|
||||
|
||||
out = {
|
||||
"publicKeyPem": pub_pem,
|
||||
"v1": {
|
||||
"licenseKey": v1_key,
|
||||
"expected": {
|
||||
"version": 1,
|
||||
"productUuid": "6f46a4f6-0d2f-4a28-b6aa-3e8fbf6f28f0",
|
||||
"licenseUuid": "11111111-2222-3333-4444-555555555555",
|
||||
"issuedAt": issued_at,
|
||||
"expiresAt": 0,
|
||||
"flags": FLAG_FINGERPRINT_BOUND,
|
||||
"isFingerprintBound": True,
|
||||
"isTrial": False,
|
||||
"entitlements": [],
|
||||
"fingerprintRaw": fingerprint_raw,
|
||||
"fingerprintHashHex": fp_hash.hex(),
|
||||
},
|
||||
},
|
||||
"v2": {
|
||||
"licenseKey": v2_key,
|
||||
"expected": {
|
||||
"version": 2,
|
||||
"productUuid": "6f46a4f6-0d2f-4a28-b6aa-3e8fbf6f28f0",
|
||||
"licenseUuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
"issuedAt": issued_at,
|
||||
"expiresAt": expires_at_v2,
|
||||
"flags": v2_flags,
|
||||
"isFingerprintBound": True,
|
||||
"isTrial": True,
|
||||
"entitlements": entitlements_v2,
|
||||
"fingerprintRaw": fingerprint_raw,
|
||||
"fingerprintHashHex": fp_hash.hex(),
|
||||
},
|
||||
},
|
||||
"v2_perpetual_unbound": {
|
||||
"licenseKey": v2_plain_key,
|
||||
"expected": {
|
||||
"version": 2,
|
||||
"productUuid": "6f46a4f6-0d2f-4a28-b6aa-3e8fbf6f28f0",
|
||||
"licenseUuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
"issuedAt": issued_at,
|
||||
"expiresAt": 0,
|
||||
"flags": 0,
|
||||
"isFingerprintBound": False,
|
||||
"isTrial": False,
|
||||
"entitlements": [],
|
||||
"fingerprintRaw": None,
|
||||
"fingerprintHashHex": "00" * 32,
|
||||
},
|
||||
},
|
||||
}
|
||||
json.dump(out, sys.stdout, indent=2)
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,138 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import {
|
||||
Verifier,
|
||||
PublicKey,
|
||||
hashFingerprint,
|
||||
parseLicenseKey,
|
||||
isExpiredAt,
|
||||
hasEntitlement,
|
||||
} from '/sessions/hopeful-determined-edison/ts-sdk-build/dist/index.js'
|
||||
|
||||
const vector = JSON.parse(readFileSync(new URL('./vector.json', import.meta.url), 'utf8'))
|
||||
|
||||
let failures = 0
|
||||
function check(name, cond, extra = '') {
|
||||
if (cond) {
|
||||
console.log(` OK ${name}`)
|
||||
} else {
|
||||
console.log(` FAIL ${name}${extra ? ': ' + extra : ''}`)
|
||||
failures++
|
||||
}
|
||||
}
|
||||
|
||||
const toHex = (b) =>
|
||||
Array.from(b).map((x) => x.toString(16).padStart(2, '0')).join('')
|
||||
|
||||
const verifier = new Verifier(PublicKey.fromPem(vector.publicKeyPem))
|
||||
|
||||
function runCase(label, caseData) {
|
||||
console.log(`\n== ${label}: parseLicenseKey + verify ==`)
|
||||
const expected = caseData.expected
|
||||
const parsed = parseLicenseKey(caseData.licenseKey)
|
||||
|
||||
check(`${label}.version`, parsed.payload.version === expected.version,
|
||||
`got ${parsed.payload.version}`)
|
||||
check(`${label}.productUuid`, parsed.payload.productUuid === expected.productUuid,
|
||||
`got ${parsed.payload.productUuid}`)
|
||||
check(`${label}.licenseUuid`, parsed.payload.licenseUuid === expected.licenseUuid,
|
||||
`got ${parsed.payload.licenseUuid}`)
|
||||
check(`${label}.issuedAt`, parsed.payload.issuedAt === expected.issuedAt)
|
||||
check(`${label}.expiresAt`, parsed.payload.expiresAt === expected.expiresAt,
|
||||
`got ${parsed.payload.expiresAt}`)
|
||||
check(`${label}.flags`, parsed.payload.flags === expected.flags,
|
||||
`got ${parsed.payload.flags}`)
|
||||
check(`${label}.isFingerprintBound`,
|
||||
parsed.payload.isFingerprintBound === expected.isFingerprintBound)
|
||||
check(`${label}.isTrial`, parsed.payload.isTrial === expected.isTrial)
|
||||
check(
|
||||
`${label}.entitlements`,
|
||||
JSON.stringify(parsed.payload.entitlements) === JSON.stringify(expected.entitlements),
|
||||
`got ${JSON.stringify(parsed.payload.entitlements)}`,
|
||||
)
|
||||
check(
|
||||
`${label}.fingerprintHash`,
|
||||
toHex(parsed.payload.fingerprintHash) === expected.fingerprintHashHex,
|
||||
)
|
||||
|
||||
// Signed bytes length depends on version + entitlements. We just assert
|
||||
// that it round-trips the signature check.
|
||||
check(`${label}.signature size`, parsed.signature.length === 64)
|
||||
|
||||
try {
|
||||
const v = verifier.verify(caseData.licenseKey)
|
||||
check(`${label}.verify()`, true)
|
||||
check(`${label}.verify productId`, v.productId === expected.productUuid)
|
||||
check(`${label}.verify licenseId`, v.licenseId === expected.licenseUuid)
|
||||
} catch (e) {
|
||||
check(`${label}.verify()`, false, String(e))
|
||||
}
|
||||
|
||||
if (expected.isFingerprintBound) {
|
||||
try {
|
||||
verifier.verifyWithFingerprint(caseData.licenseKey, expected.fingerprintRaw)
|
||||
check(`${label}.verifyWithFingerprint correct`, true)
|
||||
} catch (e) {
|
||||
check(`${label}.verifyWithFingerprint correct`, false, String(e))
|
||||
}
|
||||
let rejectedWrong = false
|
||||
try {
|
||||
verifier.verifyWithFingerprint(caseData.licenseKey, 'wrong-fingerprint')
|
||||
} catch (e) {
|
||||
rejectedWrong = String(e).toLowerCase().includes('fingerprint')
|
||||
}
|
||||
check(`${label}.verifyWithFingerprint wrong`, rejectedWrong)
|
||||
}
|
||||
|
||||
// Entitlement helper.
|
||||
for (const slug of expected.entitlements) {
|
||||
check(`${label}.hasEntitlement('${slug}')`, hasEntitlement(parsed.payload, slug))
|
||||
}
|
||||
check(`${label}.hasEntitlement('nonexistent')`,
|
||||
!hasEntitlement(parsed.payload, 'definitely-not-a-real-slug'))
|
||||
|
||||
// Expiry helper.
|
||||
if (expected.expiresAt > 0) {
|
||||
check(`${label}.isExpiredAt(before)`, !isExpiredAt(parsed.payload, expected.expiresAt - 1))
|
||||
check(`${label}.isExpiredAt(at)`, isExpiredAt(parsed.payload, expected.expiresAt))
|
||||
check(`${label}.isExpiredAt(after)`, isExpiredAt(parsed.payload, expected.expiresAt + 1))
|
||||
} else {
|
||||
check(`${label}.perpetual never expires`,
|
||||
!isExpiredAt(parsed.payload, 2_000_000_000))
|
||||
}
|
||||
|
||||
// Tamper check — flip the last base32 character of the signature.
|
||||
const orig = caseData.licenseKey
|
||||
const lastChar = orig.slice(-1)
|
||||
const flipped = lastChar === 'A' ? 'B' : 'A'
|
||||
const tampered = orig.slice(0, -1) + flipped
|
||||
let tamperRejected = false
|
||||
try {
|
||||
verifier.verify(tampered)
|
||||
} catch {
|
||||
tamperRejected = true
|
||||
}
|
||||
check(`${label}.tampered key rejected`, tamperRejected)
|
||||
}
|
||||
|
||||
runCase('v1', vector.v1)
|
||||
runCase('v2', vector.v2)
|
||||
runCase('v2_perpetual_unbound', vector.v2_perpetual_unbound)
|
||||
|
||||
console.log('\n== hashFingerprint === Python SHA-256 ==')
|
||||
check(
|
||||
'sha256 matches',
|
||||
toHex(hashFingerprint(vector.v1.expected.fingerprintRaw))
|
||||
=== vector.v1.expected.fingerprintHashHex,
|
||||
)
|
||||
|
||||
console.log('\n== pubkey loaded has correct length ==')
|
||||
check(
|
||||
'public key raw is 32 bytes',
|
||||
PublicKey.fromPem(vector.publicKeyPem).raw.length === 32,
|
||||
)
|
||||
|
||||
if (failures > 0) {
|
||||
console.log(`\nFAIL: ${failures} check(s) failed`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('\nALL CHECKS PASSED')
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=\n-----END PUBLIC KEY-----\n",
|
||||
"v1": {
|
||||
"licenseKey": "LIC1-AEAW6RVE6YGS6SRIW2VD5D57N4UPAEIRCEISEIRTGNCEIVKVKVKVKVIAAAAAAZKT6EANGRDB75RRODPGGO22UJISZYXBLLASBMODEWWNVWTHYAXFSS5DWPI-FV56FI7ZTB5GIFQHIPQ35QVVE5AO5FQGVQS45UJ5F632MLXS7VHMHYVLZWGE64FJOEXD2PVIFNE5XGRMTNOUOVEKDTW736743W25MAY",
|
||||
"expected": {
|
||||
"version": 1,
|
||||
"productUuid": "6f46a4f6-0d2f-4a28-b6aa-3e8fbf6f28f0",
|
||||
"licenseUuid": "11111111-2222-3333-4444-555555555555",
|
||||
"issuedAt": 1700000000,
|
||||
"expiresAt": 0,
|
||||
"flags": 1,
|
||||
"isFingerprintBound": true,
|
||||
"isTrial": false,
|
||||
"entitlements": [],
|
||||
"fingerprintRaw": "test-machine-fingerprint",
|
||||
"fingerprintHashHex": "d34461ff63170de633b5aa2512ce2e15ac120b1c325acdada67c02e594ba3b3d"
|
||||
}
|
||||
},
|
||||
"v2": {
|
||||
"licenseKey": "LIC1-AIBW6RVE6YGS6SRIW2VD5D57N4UPBKVKVKVLXO6MZTO533XO53XO53QAAAAAAZKT6EAAAAAAABYT7MYA2NCGD73DC4G6MM5VVISRFTROCWWBECY4GJNM3LNGPQBOLFF2HM6QEA3QOJXQY3LVNR2GSLLEMV3GSY3F-QPSJIDYL6Y5TFCKXQ2SN43EDJIZIRJZCEROM2I4MJHODT6KO4KDPW6AJ3HMYJERYPD34CF2Z46PXPYFKSRZS7BDZKVKWE57UBJSTEBI",
|
||||
"expected": {
|
||||
"version": 2,
|
||||
"productUuid": "6f46a4f6-0d2f-4a28-b6aa-3e8fbf6f28f0",
|
||||
"licenseUuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
"issuedAt": 1700000000,
|
||||
"expiresAt": 1900000000,
|
||||
"flags": 3,
|
||||
"isFingerprintBound": true,
|
||||
"isTrial": true,
|
||||
"entitlements": [
|
||||
"pro",
|
||||
"multi-device"
|
||||
],
|
||||
"fingerprintRaw": "test-machine-fingerprint",
|
||||
"fingerprintHashHex": "d34461ff63170de633b5aa2512ce2e15ac120b1c325acdada67c02e594ba3b3d"
|
||||
}
|
||||
},
|
||||
"v2_perpetual_unbound": {
|
||||
"licenseKey": "LIC1-AIAG6RVE6YGS6SRIW2VD5D57N4UPBKVKVKVLXO6MZTO533XO53XO53QAAAAAAZKT6EAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA-E4IT25ES5NBBQAXVAMZPLBDB5P2ILZL4RGKYUWEWLME5ZVGM7HGBG5CP3XHWBQ5FCYPEC6YGKBHCTQ7M7RZP7OR7NAYAMNAJAWW4QDQ",
|
||||
"expected": {
|
||||
"version": 2,
|
||||
"productUuid": "6f46a4f6-0d2f-4a28-b6aa-3e8fbf6f28f0",
|
||||
"licenseUuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
"issuedAt": 1700000000,
|
||||
"expiresAt": 0,
|
||||
"flags": 0,
|
||||
"isFingerprintBound": false,
|
||||
"isTrial": false,
|
||||
"entitlements": [],
|
||||
"fingerprintRaw": null,
|
||||
"fingerprintHashHex": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
}
|
||||
}
|
||||
}
|
||||