diff --git a/site/app.js b/site/app.js
new file mode 100644
index 0000000..9332aaa
--- /dev/null
+++ b/site/app.js
@@ -0,0 +1,185 @@
+// Notchly's hero, drawn with the app's own geometry.
+//
+// Deliberately one classic script rather than ES modules: a landing page has to work
+// when someone double-clicks index.html, and browsers refuse module imports over
+// file://. Nothing here needs a bundler or a server.
+(function () {
+
+const clamp = (value, min, max) => Math.max(min, Math.min(value, max));
+
+function canonicalPath(width, height, cornerRadius, inverseRadius) {
+ const ir = clamp(inverseRadius, 0, Math.min(width / 2, height));
+ const cr = clamp(cornerRadius, 0, Math.min((width - 2 * ir) / 2, height - ir));
+ return [
+ ["M", 0, 0],
+ ["Q", ir, 0, ir, ir],
+ ["L", ir, height - cr],
+ ["Q", ir, height, ir + cr, height],
+ ["L", width - ir - cr, height],
+ ["Q", width - ir, height, width - ir, height - cr],
+ ["L", width - ir, ir],
+ ["Q", width - ir, 0, width, 0],
+ ["Z"],
+ ];
+}
+
+function transformFor(edge, width, height) {
+ switch (edge) {
+ case "top": return (x, y) => [x, y];
+ case "bottom": return (x, y) => [x, height - y];
+ case "trailing": return (x, y) => [width - y, x];
+ case "leading": return (x, y) => [y, x];
+ default: throw new Error(`unknown edge: ${edge}`);
+ }
+}
+
+const growsHorizontally = (edge) => edge === "leading" || edge === "trailing";
+
+function notchPath(edge, width, height, cornerRadius, inverseRadius) {
+ if (width <= 0 || height <= 0) return "";
+ const [cw, ch] = growsHorizontally(edge) ? [height, width] : [width, height];
+ const commands = canonicalPath(cw, ch, cornerRadius, inverseRadius);
+ const map = transformFor(edge, width, height);
+ const round = (n) => Math.round(n * 1000) / 1000;
+ return commands
+ .map((command) => {
+ const [op, ...values] = command;
+ if (op === "Z") return "Z";
+ const points = [];
+ for (let i = 0; i < values.length; i += 2) {
+ const [x, y] = map(values[i], values[i + 1]);
+ points.push(`${round(x)} ${round(y)}`);
+ }
+ return `${op}${points.join(" ")}`;
+ })
+ .join("");
+}
+
+/** An arc reading, drawn the way the handle draws it. */
+function ring(iconPath, fraction, tone, size = 34) {
+ const stroke = Math.max(2, size * 0.08);
+ const radius = (size - stroke) / 2;
+ const circumference = 2 * Math.PI * radius;
+ const filled = circumference * clamp(fraction, 0, 1);
+ const mid = size / 2;
+ const glyph = Math.round(size * 0.5);
+ return `
+
+
+
+
+ ${iconPath}
+ `;
+}
+
+const glyphs = {
+ cpu: `
+
+
+
+ `,
+ memory: `
+
+ `,
+ battery: `
+
+ `,
+};
+
+
+const stage = document.getElementById("stage");
+const toggle = document.getElementById("toggle");
+
+let edge = "leading";
+let open = false;
+
+// Real proportions, scaled to the frame: the panel is 372 x 540 with a 26pt corner and
+// a 14pt flare; the handle is 44 deep with a 9pt flare.
+const PANEL = { depth: 258, extent: 336, corner: 26, flare: 14 };
+const HANDLE = { depth: 52, extent: 292, corner: 13, flare: 9 };
+
+const handleBody = `
+
+ ${ring(glyphs.cpu, 0.34, "normal", 30)}34%
+ ${ring(glyphs.memory, 0.68, "caution", 30)}68%
+ ${ring(glyphs.battery, 0.91, "good", 30)}91%
+
+ 14:32
+
`;
+
+const panelBody = `
+
+
+
+ 14:32
+ Tuesday 2 September
+
+
+
+ Weightless Marconi Union
+
+
+ ${ring(glyphs.cpu, 0.34, "normal", 46)}
+ ${ring(glyphs.memory, 0.68, "caution", 46)}
+ ${ring(glyphs.battery, 0.91, "good", 46)}
+
+
`;
+
+function draw() {
+ const shape = open ? PANEL : HANDLE;
+ const horizontal = growsHorizontally(edge);
+ const width = horizontal ? shape.depth : shape.extent;
+ const height = horizontal ? shape.extent : shape.depth;
+ const path = notchPath(edge, width, height, shape.corner, shape.flare);
+
+ stage.dataset.edge = edge;
+ stage.dataset.open = String(open);
+ stage.style.width = `${width}px`;
+ stage.style.height = `${height}px`;
+ stage.innerHTML = `
+
+
+
+ ${open ? panelBody : handleBody}
`;
+
+ // Restart the one piece of motion on the page: the panel arriving at its new edge.
+ stage.classList.remove("settling");
+ void stage.offsetWidth;
+ stage.classList.add("settling");
+}
+
+for (const button of document.querySelectorAll("[data-edge]")) {
+ button.addEventListener("click", () => {
+ edge = button.dataset.edge;
+ for (const other of document.querySelectorAll("[data-edge]")) {
+ other.setAttribute("aria-pressed", String(other === button));
+ }
+ draw();
+ });
+}
+
+toggle.addEventListener("click", () => {
+ open = !open;
+ toggle.textContent = open ? "Close it" : "Open it";
+ draw();
+});
+
+draw();
+
+// The star count, if GitHub is reachable and has an answer worth showing. Anything
+// else — offline, rate limited, a repo with no stars yet — leaves the button reading
+// "Star", which is the call to action anyway.
+fetch("https://api.github.com/repos/mberrishdev/Notchly")
+ .then((response) => (response.ok ? response.json() : null))
+ .then((repo) => {
+ const count = repo && repo.stargazers_count;
+ if (!count) return;
+ const node = document.getElementById("stars");
+ node.textContent = count >= 1000 ? `${(count / 1000).toFixed(1)}k` : String(count);
+ node.hidden = false;
+ })
+ .catch(() => {});
+
+})();
diff --git a/site/index.html b/site/index.html
new file mode 100644
index 0000000..a472c9e
--- /dev/null
+++ b/site/index.html
@@ -0,0 +1,135 @@
+
+
+
+
+
+Notchly — a panel for the edge of your screen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
A panel for the edge of your screen.
+
+ Notchly sits against the bezel. Closed, it shows what you want to keep an eye on.
+ Reach for it and it opens into your widgets — the five it ships with, or the ones
+ you write yourself.
+
+
+
Download
+
macOS and Windows · free · MIT
+
+
+
+
+
+
+ Dock it
+ Top
+ Right
+ Bottom
+ Left
+ Open it
+
+
+
+
+
+
+
+
+
Your widget is a folder.
+
+ No SDK, no build step, no signing. Drop a folder in and Notchly picks it up
+ immediately; save a file and it reloads while you watch. It's HTML in a webview —
+ the same kind of thing the panel itself is.
+
+
+ Widgets are sandboxed with an opaque origin. Network, shell and clipboard access
+ are refused until you grant them, and a widget's token is kept in the OS keychain
+ rather than a settings file.
+
+
+my-widget/
+ widget.json
+ index.html
+
+// widget.json
+{
+ "id": "com.you.my-widget" ,
+ "name": "My Widget" ,
+ "permissions": ["network" ]
+}
+
+// index.html
+const stats = await notchly.system.stats()
+const city = await notchly.settings.get('city' )
+
+
+
+ It doesn't phone home.
+
+ No analytics, no accounts, no network requests of its own. Clipboard history stays on
+ your machine. The whole thing is a Rust binary and a folder of HTML you can read.
+
+
+
+
+
+
+
+
diff --git a/site/styles.css b/site/styles.css
new file mode 100644
index 0000000..4790fa0
--- /dev/null
+++ b/site/styles.css
@@ -0,0 +1,312 @@
+:root {
+ --ground: #edeef2;
+ --paper: #ffffff;
+ --shell: #0b0c0f;
+ --ink: #0b0c0f;
+ --ink-2: #565d6d;
+ --ink-3: #878e9e;
+ --rule: #d8dae1;
+ --accent: #3b62d6;
+
+ --tone-normal: #6e9bff;
+ --tone-caution: #ffc46b;
+ --tone-good: #7fd1ae;
+
+ --display: "Familjen Grotesk", "Helvetica Neue", sans-serif;
+ --body: "Instrument Sans", -apple-system, BlinkMacSystemFont, sans-serif;
+ --mono: "JetBrains Mono", ui-monospace, Menlo, monospace;
+}
+
+* { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ background: var(--ground);
+ color: var(--ink);
+ font-family: var(--body);
+ font-size: 16px;
+ line-height: 1.6;
+ -webkit-font-smoothing: antialiased;
+}
+
+h1, h2 {
+ font-family: var(--display);
+ font-weight: 600;
+ letter-spacing: -0.025em;
+ line-height: 1.02;
+ margin: 0;
+ text-wrap: balance;
+}
+h1 { font-size: clamp(2.6rem, 6vw, 4.4rem); }
+h2 { font-size: clamp(1.5rem, 2.6vw, 2rem); margin-bottom: 0.6em; }
+p { margin: 0 0 1em; max-width: 62ch; text-wrap: pretty; }
+p:last-child { margin-bottom: 0; }
+.quiet { color: var(--ink-2); font-size: 0.92rem; }
+
+a { color: var(--accent); text-underline-offset: 3px; }
+a:focus-visible, button:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 3px;
+ border-radius: 3px;
+}
+
+/* ---------- masthead ---------- */
+.masthead {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 24px;
+ padding: 26px clamp(20px, 5vw, 64px);
+ flex-wrap: wrap;
+}
+.wordmark {
+ font-family: var(--display);
+ font-weight: 700;
+ font-size: 1.15rem;
+ letter-spacing: -0.03em;
+}
+.masthead nav { display: flex; align-items: center; gap: 22px; font-size: 0.92rem; }
+.masthead nav a { color: var(--ink-2); text-decoration: none; }
+.masthead nav a:hover { color: var(--ink); text-decoration: underline; }
+
+.star {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ padding: 7px 14px;
+ border: 1px solid var(--rule);
+ border-radius: 999px;
+ background: var(--paper);
+ font-weight: 500;
+}
+.masthead nav a.star:hover { color: var(--ink); border-color: var(--ink-3); text-decoration: none; }
+.star .mark { flex: 0 0 auto; }
+.star .count {
+ padding-left: 8px;
+ border-left: 1px solid var(--rule);
+ color: var(--ink-2);
+ font-variant-numeric: tabular-nums;
+}
+footer nav a { display: inline-flex; align-items: center; gap: 8px; }
+
+/* ---------- hero ---------- */
+.hero {
+ display: grid;
+ grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
+ gap: clamp(32px, 5vw, 72px);
+ align-items: center;
+ padding: clamp(24px, 4vw, 56px) clamp(20px, 5vw, 64px) clamp(56px, 7vw, 96px);
+}
+.lede { font-size: 1.08rem; color: var(--ink-2); margin-top: 1.1em; }
+.actions { display: flex; align-items: center; gap: 18px; margin-top: 2rem; flex-wrap: wrap; }
+.get {
+ display: inline-block;
+ background: var(--ink);
+ color: #fff;
+ text-decoration: none;
+ font-weight: 600;
+ font-size: 0.98rem;
+ padding: 13px 26px;
+ border-radius: 999px;
+}
+.get:hover { background: #23262f; }
+.get-note { color: var(--ink-3); font-size: 0.88rem; }
+
+/* The display: a stand-in bezel so the panel has an edge to belong to. */
+.display { margin: 0; }
+.screen {
+ position: relative;
+ aspect-ratio: 4 / 3;
+ min-height: 380px;
+ border-radius: 18px;
+ background: #cdd0d8;
+ box-shadow: inset 0 0 0 1px rgba(11, 12, 15, 0.1), 0 24px 60px -30px rgba(11, 12, 15, 0.5);
+ overflow: hidden;
+}
+.wall {
+ position: absolute;
+ inset: 0;
+ background:
+ radial-gradient(120% 90% at 12% 8%, #8f9bc4 0%, transparent 55%),
+ radial-gradient(100% 80% at 88% 24%, #c9a5a0 0%, transparent 60%),
+ linear-gradient(158deg, #3d4358 0%, #6d6478 48%, #a08a80 100%);
+}
+
+.stage {
+ position: absolute;
+ transition: opacity 0.18s ease;
+}
+.stage[data-edge="leading"] { left: 0; top: 50%; transform: translateY(-50%); }
+.stage[data-edge="trailing"] { right: 0; top: 50%; transform: translateY(-50%); }
+.stage[data-edge="top"] { top: 0; left: 50%; transform: translateX(-50%); }
+.stage[data-edge="bottom"] { bottom: 0; left: 50%; transform: translateX(-50%); }
+
+.stage .shape { position: absolute; inset: 0; }
+.stage .shape path { fill: var(--shell); }
+.stage .content { position: absolute; inset: 0; color: rgba(255, 255, 255, 0.95); }
+
+/* One orchestrated moment: the panel arriving at the edge you chose. */
+@keyframes settle {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+.stage.settling { animation: settle 0.22s ease-out; }
+
+/* closed */
+.strip {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 11px;
+ width: 100%;
+ height: 100%;
+ padding: 12px;
+}
+.stage[data-edge="leading"] .strip,
+.stage[data-edge="trailing"] .strip { flex-direction: column; }
+.ring { position: relative; display: grid; place-items: center; flex: 0 0 auto; }
+.ring svg:first-child { position: absolute; inset: 0; }
+.ring-glyph { color: rgba(255, 255, 255, 0.8); }
+.read {
+ font-size: 11px;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ letter-spacing: -0.01em;
+}
+.time { font-size: 12.5px; }
+.rule { width: 16px; height: 1px; background: rgba(255, 255, 255, 0.16); }
+.stage[data-edge="top"] .rule,
+.stage[data-edge="bottom"] .rule { width: 1px; height: 16px; }
+
+/* open */
+.panel-body {
+ display: flex;
+ flex-direction: column;
+ gap: 9px;
+ padding: 16px 26px 22px 16px;
+ height: 100%;
+}
+.stage[data-edge="trailing"] .panel-body { padding: 16px 16px 22px 26px; }
+.stage[data-edge="top"] .panel-body { padding: 16px 16px 26px; }
+.stage[data-edge="bottom"] .panel-body { padding: 26px 16px 16px; }
+.grab {
+ width: 34px;
+ height: 3px;
+ border-radius: 2px;
+ background: rgba(255, 255, 255, 0.18);
+ align-self: center;
+ flex: 0 0 auto;
+}
+.card {
+ background: rgba(255, 255, 255, 0.055);
+ border: 0.5px solid rgba(255, 255, 255, 0.1);
+ border-radius: 14px;
+ padding: 14px;
+ flex: 0 0 auto;
+}
+.clock { display: flex; flex-direction: column; gap: 5px; }
+.time-lg { font-size: 32px; font-weight: 300; letter-spacing: -0.03em; line-height: 1; font-variant-numeric: tabular-nums; }
+.sub { font-size: 11px; color: rgba(255, 255, 255, 0.58); }
+.media { display: flex; align-items: center; gap: 12px; }
+.art { width: 44px; height: 44px; border-radius: 9px; background: linear-gradient(140deg, #7b4b6b, #37506e); flex: 0 0 auto; }
+.meta { display: flex; flex-direction: column; min-width: 0; }
+.meta b { font-size: 12px; font-weight: 500; }
+.meta i { font-size: 10.5px; font-style: normal; color: rgba(255, 255, 255, 0.58); }
+.stats { display: flex; justify-content: space-around; }
+
+.edges {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ margin-top: 14px;
+ font-size: 0.82rem;
+ flex-wrap: wrap;
+ white-space: nowrap;
+}
+.edges-label { color: var(--ink-3); padding-right: 8px; }
+.edges button {
+ appearance: none;
+ border: 1px solid transparent;
+ background: transparent;
+ color: var(--ink-2);
+ font: inherit;
+ padding: 6px 12px;
+ border-radius: 999px;
+ cursor: pointer;
+ white-space: nowrap;
+}
+.edges button:hover { color: var(--ink); background: rgba(11, 12, 15, 0.06); }
+.edges button[aria-pressed="true"] { background: var(--ink); color: #fff; font-weight: 600; }
+.edges .toggle { margin-left: 10px; border-color: var(--rule); }
+.edges .toggle:hover { border-color: var(--ink-3); }
+
+/* ---------- bands ---------- */
+.band {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: clamp(28px, 4vw, 64px);
+ padding: clamp(48px, 6vw, 88px) clamp(20px, 5vw, 64px);
+ border-top: 1px solid var(--rule);
+}
+.band p { color: var(--ink-2); }
+.built-in { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
+.built-in li {
+ padding: 11px 0;
+ border-top: 1px solid var(--rule);
+ color: var(--ink-2);
+ font-size: 0.95rem;
+}
+.built-in li:first-child { border-top: 0; padding-top: 0; }
+.built-in b { color: var(--ink); font-weight: 600; margin-right: 10px; }
+
+.build {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
+ gap: clamp(28px, 4vw, 56px);
+ align-items: start;
+ padding: clamp(48px, 6vw, 88px) clamp(20px, 5vw, 64px);
+ border-top: 1px solid var(--rule);
+}
+.build p { color: var(--ink-2); }
+.code {
+ margin: 0;
+ font-family: var(--mono);
+ font-size: 0.82rem;
+ line-height: 1.75;
+ background: var(--shell);
+ color: rgba(255, 255, 255, 0.88);
+ padding: 24px;
+ border-radius: 14px;
+ overflow-x: auto;
+}
+.code .c { color: rgba(255, 255, 255, 0.38); }
+.code .s { color: var(--tone-good); }
+.code .k { color: var(--tone-normal); }
+
+.plain {
+ padding: clamp(48px, 6vw, 88px) clamp(20px, 5vw, 64px);
+ border-top: 1px solid var(--rule);
+}
+.plain p { color: var(--ink-2); }
+
+footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 32px;
+ flex-wrap: wrap;
+ padding: clamp(40px, 5vw, 72px) clamp(20px, 5vw, 64px) 72px;
+ border-top: 1px solid var(--rule);
+}
+footer .quiet { margin-top: 1.2em; max-width: 46ch; }
+footer nav { display: flex; flex-direction: column; gap: 10px; font-size: 0.92rem; }
+footer nav a { color: var(--ink-2); text-decoration: none; }
+footer nav a:hover { color: var(--ink); text-decoration: underline; }
+
+@media (max-width: 900px) {
+ .hero { grid-template-columns: 1fr; }
+ .display { min-height: 340px; }
+}
+@media (prefers-reduced-motion: reduce) {
+ .stage, .stage.settling { animation: none; transition: none; }
+}