Add design/ contract; archive prior design system as provenance
Establish keysat's durable, vendor-neutral design contract (the standards /design backfill, document-as-is): - design/DESIGN.md — nine-section brand brief distilled from the prior Claude Design system (navy-on-cream-paper identity, sovereignty-first voice, component + motion rules, do's/don'ts). Manrope is canonical display (the README's "Archivo" was a stale placeholder). - design/tokens.tokens.json — W3C DTCG tokens from colors_and_type.css. - design/brand/ — canonical palette.css + logo/mark assets. - design/_imports/2026-06-16-claude-design-system/ — the original system, relocated as dated provenance (nothing imported it). - AGENTS.md — add the Design line (read the contract before UI work); repoint the layout entry. - ROADMAP.md — design-checker cleanup backlog (gold-as-fill + pill-radius blockers, the inline-token-copy consolidation, token gaps).
@@ -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 |