Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions site/app.js
Original file line number Diff line number Diff line change
@@ -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 `<span class="ring" style="width:${size}px;height:${size}px">
<svg viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" aria-hidden="true">
<circle cx="${mid}" cy="${mid}" r="${radius}" fill="none" stroke="rgba(255,255,255,.12)" stroke-width="${stroke}"/>
<circle cx="${mid}" cy="${mid}" r="${radius}" fill="none" stroke="var(--tone-${tone})" stroke-width="${stroke}"
stroke-linecap="round" transform="rotate(-90 ${mid} ${mid})"
stroke-dasharray="${filled.toFixed(2)} ${(circumference - filled).toFixed(2)}"/>
</svg>
<svg class="ring-glyph" viewBox="0 0 16 16" width="${glyph}" height="${glyph}" aria-hidden="true">${iconPath}</svg>
</span>`;
}

const glyphs = {
cpu: `<rect x="4.5" y="4.5" width="7" height="7" rx="1.4" fill="none" stroke="currentColor" stroke-width="1.3"/>
<rect x="6.75" y="6.75" width="2.5" height="2.5" rx=".6" fill="currentColor"/>
<g stroke="currentColor" stroke-width="1.2" stroke-linecap="round">
<path d="M6.5 2.6v1.6M9.5 2.6v1.6M6.5 11.8v1.6M9.5 11.8v1.6"/>
<path d="M2.6 6.5h1.6M2.6 9.5h1.6M11.8 6.5h1.6M11.8 9.5h1.6"/></g>`,
memory: `<rect x="2.5" y="4.5" width="11" height="7" rx="1.2" fill="none" stroke="currentColor" stroke-width="1.3"/>
<g stroke="currentColor" stroke-width="1.2" stroke-linecap="round">
<path d="M5.5 6.8v2.4M8 6.8v2.4M10.5 6.8v2.4"/><path d="M4.6 11.5v1.4M11.4 11.5v1.4"/></g>`,
battery: `<rect x="1.8" y="5" width="11" height="6" rx="1.8" fill="none" stroke="currentColor" stroke-width="1.3"/>
<path d="M14.2 7v2" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
<rect x="3.4" y="6.6" width="5" height="2.8" rx=".8" fill="currentColor"/>`,
};


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 = `
<div class="strip">
${ring(glyphs.cpu, 0.34, "normal", 30)}<span class="read">34%</span>
${ring(glyphs.memory, 0.68, "caution", 30)}<span class="read">68%</span>
${ring(glyphs.battery, 0.91, "good", 30)}<span class="read">91%</span>
<span class="rule"></span>
<span class="read time">14:32</span>
</div>`;

const panelBody = `
<div class="panel-body">
<span class="grab"></span>
<div class="card clock">
<span class="time-lg">14:32</span>
<span class="sub">Tuesday 2 September</span>
</div>
<div class="card media">
<span class="art"></span>
<span class="meta"><b>Weightless</b><i>Marconi Union</i></span>
</div>
<div class="card stats">
${ring(glyphs.cpu, 0.34, "normal", 46)}
${ring(glyphs.memory, 0.68, "caution", 46)}
${ring(glyphs.battery, 0.91, "good", 46)}
</div>
</div>`;

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 = `
<svg class="shape" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" aria-hidden="true">
<path d="${path}"/>
</svg>
<div class="content">${open ? panelBody : handleBody}</div>`;

// 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(() => {});

})();
135 changes: 135 additions & 0 deletions site/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Notchly — a panel for the edge of your screen</title>
<meta name="description" content="Notchly docks a widget panel to any edge of your display. Five widgets built in, and your own are a folder with an index.html. macOS and Windows, MIT.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Familjen+Grotesk:wght@500;600;700&family=Instrument+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap">
<link rel="stylesheet" href="styles.css">
</head>
<body>

<header class="masthead">
<span class="wordmark">Notchly</span>
<nav>
<a href="#widgets">Widgets</a>
<a href="#build">Build one</a>
<!-- The count is filled in from GitHub if it answers, and simply omitted if it
doesn't. A star button that reads "Star ·" is worse than one that reads "Star". -->
<a class="star" href="https://github.com/mberrishdev/Notchly">
<svg class="mark" viewBox="0 0 16 16" width="15" height="15" aria-hidden="true"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg><span>Star</span><span id="stars" class="count" hidden></span>
</a>
</nav>
</header>

<!-- The hero is the product, drawn with the same path maths the app uses. -->
<section class="hero">
<div class="hero-copy">
<h1>A panel for the edge of your screen.</h1>
<p class="lede">
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.
</p>
<div class="actions">
<a class="get" href="https://github.com/mberrishdev/Notchly/releases/latest">Download</a>
<span class="get-note">macOS and Windows · free · MIT</span>
</div>
</div>

<figure class="display" aria-label="Notchly docked to the edge of a display">
<div class="screen">
<div class="wall"></div>
<div id="stage" class="stage"></div>
</div>
<figcaption class="edges">
<span class="edges-label">Dock it</span>
<button data-edge="top">Top</button>
<button data-edge="trailing">Right</button>
<button data-edge="bottom">Bottom</button>
<button data-edge="leading" aria-pressed="true">Left</button>
<button id="toggle" class="toggle">Open it</button>
</figcaption>
</figure>
</section>

<section class="band" id="widgets">
<div class="col">
<h2>Closed, it still answers things.</h2>
<p>
The handle holds readings you pick — the time, load, memory, battery, what's playing.
Anything that's a fraction of something draws an arc, so you can read it without
stopping to read it. Rest on one and its detail appears without opening the panel.
</p>
</div>
<div class="col">
<h2>Open, it's whatever you put in it.</h2>
<ul class="built-in">
<li><b>Clock</b> time, date, week number</li>
<li><b>Now Playing</b> controls for Music and Spotify</li>
<li><b>System</b> CPU, memory, disk, network, battery</li>
<li><b>Launcher</b> search and open any app</li>
<li><b>Clipboard</b> searchable history of what you copied</li>
</ul>
</div>
</section>

<section class="build" id="build">
<div class="build-copy">
<h2>Your widget is a folder.</h2>
<p>
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.
</p>
<p class="quiet">
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.
</p>
</div>
<pre class="code" aria-label="A widget folder"><span class="c">my-widget/</span>
widget.json
index.html

<span class="c">// widget.json</span>
{
"id": <span class="s">"com.you.my-widget"</span>,
"name": <span class="s">"My Widget"</span>,
"permissions": [<span class="s">"network"</span>]
}

<span class="c">// index.html</span>
<span class="k">const</span> stats = <span class="k">await</span> notchly.system.stats()
<span class="k">const</span> city = <span class="k">await</span> notchly.settings.get(<span class="s">'city'</span>)</pre>
</section>

<section class="plain">
<h2>It doesn't phone home.</h2>
<p>
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.
</p>
</section>

<footer>
<div>
<a class="get" href="https://github.com/mberrishdev/Notchly/releases/latest">Download Notchly</a>
<p class="quiet">
Builds are unsigned for now, so macOS calls the app damaged and Windows shows
SmartScreen. The readme has the two commands that clear it.
</p>
</div>
<nav>
<a href="https://github.com/mberrishdev/Notchly"><svg class="mark" viewBox="0 0 16 16" width="15" height="15" aria-hidden="true"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg>Source on GitHub</a>
<a href="https://github.com/mberrishdev/Notchly/blob/main/docs/WidgetAPI.md">Widget API</a>
<a href="https://github.com/mberrishdev/Notchly/issues">Report a problem</a>
</nav>
</footer>

<script src="app.js" defer></script>
</body>
</html>
Loading
Loading