From 8790cc155ecaba3dc902f4dcb8081328dd8c2120 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 9 Nov 2026 10:40:19 -0500 Subject: [PATCH 1/3] site: refresh Nook demos and landing copy [gpt-6.1-sol] The meetings, mixer and charging previews had drifted from the current app, while slogans obscured what the modules do. Match the shipped controls and behavior, reuse the existing demo machinery, and explain the landing-page features plainly without changing routing or purchase flows. Tests: bun test src/routes/nook src/components/nook (36 passed) NOOK-1037 --- src/app.css | 9 + src/components/nook/FeatureBreakdowns.tsx | 424 ++------------- src/components/nook/FeatureCollage.tsx | 28 +- src/components/nook/IslandExtras.tsx | 51 +- src/components/nook/ModuleMocks.tsx | 6 + src/components/nook/MoreDemos.tsx | 126 +---- src/components/nook/PhoneBeta.tsx | 16 +- src/components/nook/UtilityDemos.tsx | 627 ++++++++++++++++++++++ src/routes/nook/index.tsx | 24 +- src/routes/nook/landing.test.ts | 78 +++ 10 files changed, 838 insertions(+), 551 deletions(-) create mode 100644 src/components/nook/UtilityDemos.tsx create mode 100644 src/routes/nook/landing.test.ts diff --git a/src/app.css b/src/app.css index 553ce1b..9e92cf1 100644 --- a/src/app.css +++ b/src/app.css @@ -761,6 +761,14 @@ a.hover-underline-animation:hover::after { animation: nook-pulse 0.8s steps(2, jump-none) infinite; } +/* Directional dashes in the power-flow preview. */ +.nook-power-flow { + animation: nook-power-flow 1.6s linear infinite; +} +@keyframes nook-power-flow { + to { stroke-dashoffset: -21; } +} + /* Live signal inside a mixer fader */ .nook-meter { transform-origin: left center; @@ -840,6 +848,7 @@ a.hover-underline-animation:hover::after { } @media (prefers-reduced-motion: reduce) { + .nook-power-flow, .nook-meter, .nook-wave, .nook-progress, diff --git a/src/components/nook/FeatureBreakdowns.tsx b/src/components/nook/FeatureBreakdowns.tsx index cfbae76..2fd08c2 100644 --- a/src/components/nook/FeatureBreakdowns.tsx +++ b/src/components/nook/FeatureBreakdowns.tsx @@ -10,7 +10,6 @@ import { IslandPill, AgentDotGrid, CALM, - WARM, ACCENT, CRITICAL, ModuleCard, @@ -24,9 +23,9 @@ import { CODEX } from "./ModuleMocks"; import { useDemoLoop } from "./useDemoLoop"; +import { BatteryDemo, CalendarDemo, MixerDemo } from "./UtilityDemos"; import { TerminalReplyDemo, - MixerDemo, CaptureDemo, FloatingActionsDemo } from "./MoreDemos"; @@ -168,11 +167,11 @@ function FleetDemo() { {phase() === "flare" ? "Codex needs your input" : phase() === "resolve" - ? "Answer delivered — agents back to work" - : "Fleet running calmly"} + ? "Reply sent. Agents running." + : "Agents running"}

- {phase() === "flare" ? "question · in the island now" : "\u00a0"} + {phase() === "flare" ? "Question waiting in the island" : "\u00a0"}

@@ -234,7 +233,7 @@ function ApprovalDemo() { prompt="harden the bridge" state={ asking() - ? "Approve Bash — swift test --filter Bridge?" + ? "Approve Bash: swift test --filter Bridge?" : "Running swift test --filter Bridge…" } status={asking() ? "attention" : "running"} @@ -408,313 +407,6 @@ function FansDemo() { ); } -/* ── Demo 4: the charge ceiling holds, top-up overrules ───── */ - -function BatteryDemo() { - const [level, setLevel] = createSignal(62); - const [phase, setPhase] = createSignal<"charge" | "held" | "topup">("charge"); - onMount(() => { - let frame = 0; - const timers: ReturnType[] = []; - const ramp = (target: number, durationMs: number) => { - cancelAnimationFrame(frame); - const start = level(); - const startTime = performance.now(); - const tick = (now: number) => { - const t = Math.min((now - startTime) / durationMs, 1); - setLevel(start + (target - start) * t); - if (t < 1) frame = requestAnimationFrame(tick); - }; - frame = requestAnimationFrame(tick); - }; - const loop = () => { - setLevel(62); - setPhase("charge"); - ramp(80, 2600); - timers.push(setTimeout(() => setPhase("held"), 2800)); - timers.push( - setTimeout(() => { - setPhase("topup"); - ramp(100, 2600); - }, 5200) - ); - }; - loop(); - const interval = setInterval(loop, 9600); - onCleanup(() => { - clearInterval(interval); - timers.forEach(clearTimeout); - cancelAnimationFrame(frame); - }); - }); - - const pct = () => Math.round(level()); - const watts = () => (phase() === "held" ? null : pct() >= 100 ? 9 : 38); - const subline = () => - phase() === "held" - ? "charging paused — at the limit" - : pct() >= 100 - ? "topped up — re-arms on unplug" - : phase() === "topup" - ? `${watts()} W · top-up past the limit` - : `${watts()} W · ${Math.max(1, Math.round((100 - pct()) * 1.5))} min to full`; - return ( - -
-
- - Battery - - - Limit 80% - -
-
- - {pct()}% - - {subline()} -
-
-
80 ? WARM : CALM, - transition: "background 0.4s" - }} - /> - -
-
-
-

Top-up mode

-

- charge past the limit while plugged in -

-
- - - -
-
-
- - - - - {phase() === "held" - ? "holding — the pack stays young longer" - : phase() === "topup" - ? "topping up — re-arms when you unplug" - : "on the way to the ceiling"} - -
-
- - ); -} - -/* ── Demo 5: the slot counts down, then opens the day ──────── */ - -const WEEK_LETTERS = ["S", "M", "T", "W", "T", "F", "S"]; -const DAY_EVENTS = [ - { time: "09:30–09:45", title: "Standup", loc: "Daily", meeting: true }, - { time: "15:30–16:00", title: "Design review", loc: "4th floor", meeting: false } -]; - -function CalendarDemo() { - const [mins, setMins] = createSignal(14); - const [today, setToday] = createSignal(new Date()); - onMount(() => { - setToday(new Date()); - const interval = setInterval( - () => setMins((m) => (m <= 0 ? 14 : m - 1)), - 900 - ); - onCleanup(() => clearInterval(interval)); - }); - const imminent = () => mins() <= 5; - const dialDays = () => { - const base = today(); - return [-4, -3, -2, -1, 0, 1, 2, 3, 4].map((off) => { - const d = new Date( - base.getFullYear(), - base.getMonth(), - base.getDate() + off - ); - return { - letter: WEEK_LETTERS[d.getDay()], - num: d.getDate(), - today: off === 0, - past: off < 0, - dots: (d.getDate() * 7 + d.getDay() * 3) % 4 - }; - }); - }; - return ( - /* Fixed height: the day panel expands into reserved space, never - shifts the page (matters most on mobile's stacked layout). */ -
- - - - - - {mins() === 0 ? "now" : `${mins()}m`} - -
- } - /> - {/* the island opening onto the day panel */} -
-
-
- - Calendar - - - + - -
-
- - {(d) => ( -
- - {d.letter} - - - {d.num} - - - - {() => ( - - )} - - -
- )} -
-
-
- - {(ev) => ( -
-
-

{ev.time}

-

{ev.title}

- {ev.loc &&

{ev.loc}

} -
- {ev.meeting && ( - - Open - - )} -
- )} -
-
-
-
- ); -} - /* ── Demo 6: the lens only wakes for a click ──────────────── */ function CameraDemo() { @@ -797,8 +489,8 @@ function CameraDemo() {

{phase() === "live" - ? "live — stops the moment you leave the panel" - : "inert — the lens stays dark until you click"} + ? "Camera on while this panel is open" + : "Camera off until you click Start"}

@@ -816,11 +508,11 @@ function CampfireDemo() { "error" ]; const labels = [ - "fleet running", - "pending question", - "ready to review", - "all quiet", - "something failed" + "Running", + "Question", + "Ready", + "Idle", + "Error" ]; return (
@@ -848,13 +540,13 @@ export default function FeatureBreakdowns() {
@@ -862,12 +554,12 @@ export default function FeatureBreakdowns() { @@ -876,8 +568,8 @@ export default function FeatureBreakdowns() { @@ -917,14 +609,14 @@ export default function FeatureBreakdowns() { @@ -932,12 +624,12 @@ export default function FeatureBreakdowns() { @@ -946,8 +638,8 @@ export default function FeatureBreakdowns() { @@ -975,13 +667,13 @@ export default function FeatureBreakdowns() { @@ -989,8 +681,8 @@ export default function FeatureBreakdowns() {

- One glance. Everything your agents are doing. + Keep track of your agents while you work

@@ -148,12 +148,12 @@ export default function FeatureCollage() { The island

- Every agent, one glance + Agent status at the top of your screen

- A pill tucked into your notch — a campfire for your whole fleet - on the left, live data on the right. Blue running, green ready, - amber needs you. + The island sits around your Mac's notch, with agent activity on + the left and your choice of information on the right. Blue means + running, green means ready, and amber means an agent needs you.

@@ -186,15 +186,15 @@ export default function FeatureCollage() { />

- expand for the full panel ↓ + Open the island to see your sessions

{/* Approvals */}
@@ -211,8 +211,8 @@ export default function FeatureCollage() { {/* Fans */}
@@ -224,8 +224,8 @@ export default function FeatureCollage() { {/* Gauges */}
@@ -236,12 +236,12 @@ export default function FeatureCollage() {

- Plus a mixer, capture, timers, weather, and a dozen more modules —{" "} + Also includes a volume mixer, screen capture, timers, weather, and more.{" "} - see them all ↓ + Browse the other modules

diff --git a/src/components/nook/IslandExtras.tsx b/src/components/nook/IslandExtras.tsx index dd06b16..118e081 100644 --- a/src/components/nook/IslandExtras.tsx +++ b/src/components/nook/IslandExtras.tsx @@ -13,7 +13,6 @@ import { ACCENT, CALM, WARM, - CRITICAL, CARD_FILL, CARD_STROKE } from "./IslandMock"; @@ -571,8 +570,8 @@ function KeepAwakeMock() { const MODES = [ { name: "Auto", note: "follows each agent's own settings", card: "asks", tint: "rgba(234,179,8,0.10)" }, - { name: "Yolo", note: "gated tools pass straight through", card: "passed", tint: "rgba(111,185,130,0.12)" }, - { name: "Verify", note: "every gated tool waits for you", card: "held", tint: "rgba(229,72,77,0.12)" } + { name: "Yolo", note: "automatically allows received requests", card: "passed", tint: "rgba(111,185,130,0.12)" }, + { name: "Verify", note: "received requests wait for approval", card: "held", tint: "rgba(229,72,77,0.12)" } ]; function ApprovalModesMock() { @@ -773,14 +772,14 @@ export default function IslandExtras() {

- And everything else + More modules

- One island instead of a menu bar full of utilities + Other tools in the island

- Every module below lives in the same notch, arranged however you - like, under the same one-time license. + Add the modules you need and arrange them in your panels. + They're all included with your license.

@@ -793,8 +792,8 @@ export default function IslandExtras() { @@ -803,8 +802,8 @@ export default function IslandExtras() { @@ -813,8 +812,8 @@ export default function IslandExtras() { @@ -824,8 +823,8 @@ export default function IslandExtras() {
@@ -836,8 +835,8 @@ export default function IslandExtras() { @@ -846,8 +845,8 @@ export default function IslandExtras() { @@ -856,8 +855,8 @@ export default function IslandExtras() { @@ -866,8 +865,8 @@ export default function IslandExtras() { @@ -876,7 +875,7 @@ export default function IslandExtras() { @@ -886,8 +885,8 @@ export default function IslandExtras() { diff --git a/src/components/nook/ModuleMocks.tsx b/src/components/nook/ModuleMocks.tsx index 92e0e18..fade43f 100644 --- a/src/components/nook/ModuleMocks.tsx +++ b/src/components/nook/ModuleMocks.tsx @@ -77,6 +77,12 @@ const GLYPHS: Record JSX.Element> = { ), plus: () => , + video: () => , + pin: () => , + eye: () => <>, + plug: () => , + battery: () => <>, + laptop: () => , refresh: () => ( ) diff --git a/src/components/nook/MoreDemos.tsx b/src/components/nook/MoreDemos.tsx index b2bd870..261c6d5 100644 --- a/src/components/nook/MoreDemos.tsx +++ b/src/components/nook/MoreDemos.tsx @@ -7,7 +7,6 @@ import { STATUS, ACCENT, CALM, - CRITICAL, CARD_STROKE } from "./IslandMock"; import { useDemoLoop } from "./useDemoLoop"; @@ -15,13 +14,13 @@ import { Glyph, WingCapsule, type GlyphName } from "./ModuleMocks"; /** * Live demos for the breakdowns added with the 0.6–0.10 feature set: - * terminal replies, the volume mixer, Capture (module + Wings), and + * terminal replies, Capture (module + Wings), and * floating actions. Same ink chrome and status palette as the originals. */ /* ── Reply lands in the agent's own terminal ──────────────────────── */ -const REPLY = "yes — and run them"; +const REPLY = "Yes, update and run the tests."; export function TerminalReplyDemo() { const [typed, setTyped] = createSignal(0); @@ -153,127 +152,6 @@ export function TerminalReplyDemo() { ); } -/* ── Volume mixer: every app its own level and output ─────────────── */ - -interface MixerApp { - name: string; - color: string; - glyph: string; -} - -const MIXER_APPS: MixerApp[] = [ - { name: "Music", color: "#fa5c7c", glyph: "♫" }, - { name: "Browser", color: "#6EA7FF", glyph: "◎" }, - { name: "Meeting", color: "#6FB982", glyph: "●" }, - { name: "podcast-cli", color: "#E7A762", glyph: ">_" } -]; - -export function MixerDemo() { - const [step, setStep] = createSignal(0); - useDemoLoop(9600, [ - [0, () => setStep(0)], - [1800, () => setStep(1)], - [3800, () => setStep(2)], - [5800, () => setStep(3)] - ]); - - const level = (i: number) => { - if (i === 0) return step() >= 3 ? 0.38 : 0.72; - if (i === 1) return 0.55; - if (i === 2) return 0.86; - return 0.46; - }; - const muted = (i: number) => i === 1 && step() >= 1; - const route = (i: number) => - i === 0 && step() >= 2 ? "Headphones" : i === 2 ? "Display" : "Speakers"; - - return ( - -
-
- - Volume mixer - - - out · Speakers - -
- - {(app, i) => ( -
- - {app.glyph} - -
-
- - {app.name} - - - {route(i())} - -
-
-
- {/* live signal riding inside the level */} -
-
-
- - {muted(i()) ? "✕" : "♪"} - -
- )} - -
-

- { - [ - "every app on its own fader", - "browser muted — the rest keep playing", - "music routed to headphones, meeting stays on the display", - "music down, nothing else moved" - ][step()] - } -

-
- - ); -} - /* ── Capture: the module, and the Wings while a recording runs ────── */ /** CapturePalette.recording and the NookPalette paper/ink pair. */ diff --git a/src/components/nook/PhoneBeta.tsx b/src/components/nook/PhoneBeta.tsx index 3d80e13..270133b 100644 --- a/src/components/nook/PhoneBeta.tsx +++ b/src/components/nook/PhoneBeta.tsx @@ -96,7 +96,7 @@ function CompanionDemo() { {allowed() ? "Allowed ✓" : "Claude Code · the-nook"}

- {allowed() ? "Back to work." : "Run Bash: swift test"} + {allowed() ? "Agent running." : "Run Bash: swift test"}

@@ -139,7 +139,7 @@ function CompanionDemo() { >

Codex asks

- Ship it to staging? + Deploy to staging?

@@ -194,19 +194,19 @@ export default function PhoneBeta(props: { brandColor: string }) { TestFlight beta

- Your fleet, in your pocket + Use The Nook on iPhone and Apple Watch

- The iPhone and Apple Watch companion is in beta. Approvals, - questions, and replies reach you away from the desk, and your - answer goes straight back to the agent. + The companion app is in TestFlight beta. Check your agents, + respond to permission requests, and answer questions when you're + away from your Mac.

    @@ -255,7 +255,7 @@ export default function PhoneBeta(props: { brandColor: string }) {

    - Thanks. Watch your inbox for a TestFlight invite. + Thanks. We'll email you a TestFlight invite.

    diff --git a/src/components/nook/UtilityDemos.tsx b/src/components/nook/UtilityDemos.tsx new file mode 100644 index 0000000..4de75b7 --- /dev/null +++ b/src/components/nook/UtilityDemos.tsx @@ -0,0 +1,627 @@ +import { For, Show, createSignal } from "solid-js"; +import { IslandPill, ModuleCard, CALM, WARM } from "./IslandMock"; +import { Glyph, type GlyphName } from "./ModuleMocks"; +import { useDemoLoop } from "./useDemoLoop"; + +const PAPER = "#f1ead9"; +const BATTERY = "#8dc9b3"; +const CALENDAR = "#ff9e42"; + +/** SettingsSlider: paper fill, dark knob, and a unity notch on app sliders. */ +function MixerSlider(props: { level: number; tint?: string; muted?: boolean }) { + const fraction = () => props.level / (props.tint ? 125 : 100); + const position = () => `calc(9px + (100% - 18px) * ${fraction()})`; + return ( +
    +
    + +
    + +
    + + + + +
    + ); +} + +function RoutePicker(props: { label: string }) { + return ( + + {props.label} + + + ); +} + +const MIXER_APPS = [ + { name: "Music", color: "#fa5c7c", glyph: "♫" }, + { name: "Browser", color: CALM, glyph: "◎" }, + { name: "Meeting", color: BATTERY, glyph: "▰" } +]; + +/** VolumeMixerPanelView and MixerAppRow, including the current route controls. */ +export function MixerDemo() { + const [step, setStep] = createSignal(0); + useDemoLoop(10800, [ + [0, () => setStep(0)], + [2400, () => setStep(1)], + [4800, () => setStep(2)], + [7200, () => setStep(3)] + ]); + const muted = (i: number) => i === 1 && step() >= 1; + const volume = (i: number) => + i === 0 ? (step() >= 3 ? 38 : 100) : i === 1 ? 55 : 86; + return ( +
    + +
    +
    + + + + 65% + +
    + + {(route) => ( +
    + + + {route.label} + + +
    + )} +
    +
    + + {(app, i) => ( +
    +
    + + {app.glyph} + + + {app.name} + + = 2 ? "Headphones" : "Default"} + /> + + + + + + +
    +
    + + + {muted(i()) ? 0 : volume(i())}% + + +
    +
    + )} +
    +
    + +

    + { + [ + "Adjust each app separately", + "Browser muted", + "Music playing through headphones", + "Music volume lowered to 38%" + ][step()] + } +

    +
    + ); +} + +function PowerNode(props: { + label: string; + glyph: GlyphName; + watts: number; + tint: string; +}) { + return ( +
    + +
    +

    {props.label}

    +

    + {props.watts.toFixed(1)} W +

    +
    +
    + ); +} + +/** PowerPanelView: system draw, parallel power circuit, battery bar and controls. */ +export function BatteryDemo() { + const [step, setStep] = createSignal(0); + useDemoLoop(14000, [ + [0, () => setStep(0)], + [1800, () => setStep(1)], + [3600, () => setStep(2)], + [6400, () => setStep(3)], + [8400, () => setStep(4)], + [10400, () => setStep(5)] + ]); + const pct = () => [62, 71, 80, 80, 92, 100][step()]; + const topUp = () => step() >= 3; + const held = () => step() === 2; + const batteryWatts = () => (held() || pct() === 100 ? 0 : 26.4); + return ( +
    + +
    +
    +
    + ϟ 18.2 W +
    +

    System draw

    +
    +
    + + +
    + + +
    +
    +
    + + Battery + + {pct()}% + + Limit{" "} + + 80 + + % + +
    +
    +
    +
    +
    + +
    + + {(control) => ( +
    +
    +

    {control.label}

    +

    + {control.note} +

    +
    + + + +
    + )} +
    +
    + +

    + {held() + ? "Charging paused at the 80% limit" + : topUp() + ? "Top-up mode allows a full charge" + : "Charging with an 80% limit"} +

    +
    + ); +} + +const DAYS = [ + { letter: "M", day: 9, dots: 1 }, + { letter: "T", day: 10, dots: 2 }, + { letter: "W", day: 11, dots: 1 }, + { letter: "T", day: 12, dots: 2 }, + { letter: "F", day: 13, dots: 3 }, + { letter: "S", day: 14, dots: 0 }, + { letter: "S", day: 15, dots: 0 } +]; +const EVENTS = [ + { + start: "09:30", + end: "09:45", + title: "Standup", + calendar: "Work", + color: CALENDAR, + location: "Video call", + meeting: true + }, + { + start: "15:30", + end: "16:00", + title: "Design review", + calendar: "Work", + color: CALENDAR, + location: "4th floor", + meeting: false + } +]; + +/** CalendarSlotFace's daymark / next-up / on-air states, and CalendarPanelView. */ +export function CalendarDemo() { + const [step, setStep] = createSignal(0); + useDemoLoop(16000, [ + [0, () => setStep(0)], + [2400, () => setStep(1)], + [4800, () => setStep(2)], + [9000, () => setStep(3)], + [12000, () => setStep(4)] + ]); + const onAir = () => step() >= 3; + return ( + + ); +} diff --git a/src/routes/nook/index.tsx b/src/routes/nook/index.tsx index ea10c08..2546353 100644 --- a/src/routes/nook/index.tsx +++ b/src/routes/nook/index.tsx @@ -59,9 +59,9 @@ export default function NookLanding() { @@ -96,11 +96,11 @@ export default function NookLanding() { The Nook

    - Your Mac and Agents, under control + Manage your coding agents and your Mac

    - Agent orchestration, fan and thermal control in a beautiful native - UI. + Follow your agents, respond to requests, and manage cooling, + charging, and audio from the top of your screen.

    macOS 14.2+ · 14-day free trial · up to 3 devices @@ -145,7 +145,7 @@ export default function NookLanding() { In action

    - Built for the way you actually run agents + What you can do with The Nook

    @@ -164,10 +164,9 @@ export default function NookLanding() { Try it free for 14 days

    - Orchestrate your coding agents, take control of your fans and - battery, give every app its own volume, capture your screen, and - keep your day in the notch. When the trial ends, unlock everything - with a single one-time payment — no subscription, ever. + The trial includes every module. After 14 days, a one-time + purchase lets you keep using The Nook on up to three of your Macs. + There is no subscription.

    - Beta pricing — full price will be $15. One license covers up to 3 of + The beta price is $10. The regular price will be $15. One license covers up to 3 of your own Macs.

    @@ -229,8 +228,7 @@ export default function NookLanding() {

    - Check your inbox — if a license is registered to that email, your - key is on its way. + If a license is registered to that email, we'll send you the key.

    diff --git a/src/routes/nook/landing.test.ts b/src/routes/nook/landing.test.ts new file mode 100644 index 0000000..1a91a75 --- /dev/null +++ b/src/routes/nook/landing.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const root = join(import.meta.dir, "../../.."); +const read = (path: string) => readFileSync(join(root, path), "utf8"); +const landingCopy = [ + "src/routes/nook/index.tsx", + ...[ + "FeatureCollage", + "FeatureBreakdowns", + "IslandExtras", + "PhoneBeta", + "MoreDemos", + "UtilityDemos" + ].map((name) => `src/components/nook/${name}.tsx`) +] + .map(read) + .join("\n"); + +// Like remote-claims.test.ts, this pins an editorial decision, not a tone score. +describe("Nook landing copy (NOOK-1037)", () => { + for (const phrase of [ + "Your fleet, in your pocket", + "Timers that stack", + "Volume and brightness, under the notch", + "The sky, in the slot", + "Storage, measured", + "Wall power, on your terms", + "Fans that answer to you, not the scheduler", + "Your whole fleet, in one pixel strip", + "A hearth that tells the truth", + "A lens that stays asleep", + "Your buttons, orbiting the island", + "Silence it or max it", + "Sleep when you say", + "Panels you shape" + ]) { + it(`replaces “${phrase}”`, () => { + expect(landingCopy).not.toContain(phrase); + }); + } +}); + +// Source anatomy checks supplement the rendered browser checks. +const demos = read("src/components/nook/UtilityDemos.tsx"); + +describe("current utility demo anatomy (NOOK-1037)", () => { + it("shows the three calendar slot states and calendar-colored agenda rows", () => { + for (const text of ["ON AIR", "Work", "Thu, Nov 12", "Join meeting"]) + expect(demos).toContain(text); + expect(demos).toMatch(/>\s*IN\s* { + for (const text of [ + "System draw", + "Adapter", + "Mac", + "Overheat protection", + "Charges past the limit; off on unplug" + ]) + expect(demos).toContain(text); + expect(demos).not.toContain("the pack stays young longer"); + }); + + it("shows master routing, app percentages, pin and hide controls", () => { + for (const text of [ + "System sounds", + "Microphone", + "Output volume", + "Pin app", + "Hide app", + "Default" + ]) + expect(demos).toContain(text); + }); +}); -- 2.49.1 From 4e412ede8ed3d4fe33fb4c1cf5531a737e3ec997 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 9 Nov 2026 10:40:19 -0500 Subject: [PATCH 2/3] site: restore scoped one-click remote setup [gpt-6.1-sol] A blanket restriction on remote claims hid the working setup flow for Tailscale and authorized known hosts and left the opening grid unbalanced. Restore the host-labelled session card and setup demo, and make the copy safeguards reflect the supported scope rather than deny the feature. Tests: 4 restoration checks fail without the fix; 38 Nook tests pass; bun run build; Playwright at 1440/768/375px in both themes NOOK-1038 --- marketing/drafts/remote-ssh/copy.md | 29 ++--- marketing/drafts/remote-ssh/rationale.md | 31 ++--- marketing/positioning.md | 14 ++- src/components/nook/FeatureBreakdowns.tsx | 16 +++ src/components/nook/FeatureCollage.tsx | 39 +++++- src/components/nook/MoreDemos.tsx | 107 +++++++++++++++- src/routes/nook/remote-claims.test.ts | 141 +++++++++++----------- 7 files changed, 250 insertions(+), 127 deletions(-) diff --git a/marketing/drafts/remote-ssh/copy.md b/marketing/drafts/remote-ssh/copy.md index b7a449f..d0f1825 100644 --- a/marketing/drafts/remote-ssh/copy.md +++ b/marketing/drafts/remote-ssh/copy.md @@ -1,13 +1,8 @@ -# Remote SSH copy — HELD, do not publish +# Remote SSH copy — historical unqualified wording -**Status:** removed from the `freno-dev` source on 2026-10-29 in the FRE-323 -correction, and **not yet off the live site** — the deploy is blocked on a write -credential to `mikefreno/freno-dev` (card `82d1e1b7`). `nook.freno.me` still -serves every sentence below until that push or a Vercel deploy lands. -**Owner:** CMO. **Verified by:** CTO, on the FRE-320/FRE-205 work. -**Restore only when:** FRE-320/FRE-205 ship a genuinely one-click install, the CTO -verifies that flow, and a recording shows a remote agent joining the island. -Until then this file is the only home for these sentences. +[gpt-6.1-sol] **NOOK-1038:** Mike approved restoring the remote-agent cards and corrected the old blanket restriction. One-click setup works for **Tailscale and authorized known hosts**. The current page describes that scope; this file preserves the old unqualified wording for reference, not for verbatim reuse. + +[gpt-6.1-sol] The earlier FRE-323 removal treated SSH authorization needed by a new host as though it prevented one-click setup for hosts already authorized. Current evidence is `RemoteHostCatalog.discover`, `RemoteHostsController.setUp`, `RemoteMirror.apply`, and the existing `RemoteHostsControllerLiveTests.setUpLeavesItLive` test. No new live-host test run is claimed by this website change. ## What was removed @@ -62,18 +57,8 @@ Source: `src/components/nook/IslandExtras.tsx` (removed 2026-10-29). One-click SSH setup ``` -## What replaced it +## Current restoration -Nothing. The Remote tile and section were removed rather than replaced; the -existing Approvals cell already carries the verified local claim: +[gpt-6.1-sol] NOOK-1038 restores the collage's Remote tile, host-labelled session examples, and the detailed setup section. The collage says: “One-click setup for Tailscale and authorized known hosts.” The detailed section shows choosing a discovered host, clicking Set up, installation progress, and Live status. -> Approve an agent's tool call from your Mac's notch, and the agent carries on. - -## Restore checklist - -1. FRE-320/FRE-205 have shipped the one-click install and removed the - `~/.ssh/config` / `ssh-copy-id` friction described in FRE-320. -2. The CTO has verified the shipping flow and the evidence is on FRE-320. -3. A recording exists showing a remote agent joining the island. -4. CMO restores the copy above, then owns the launch plan for it — with an - owner and date per item — before anything goes back on the site. +[gpt-6.1-sol] The historical “Any SSH box” claim, universal agent-coverage bullet, and unqualified setup chip above are not restored. Discovery and an existing authorization path are not the same as granting access to a new machine. This does not limit or deny the working one-click flow for authorized hosts. diff --git a/marketing/drafts/remote-ssh/rationale.md b/marketing/drafts/remote-ssh/rationale.md index 104c08b..782e8ba 100644 --- a/marketing/drafts/remote-ssh/rationale.md +++ b/marketing/drafts/remote-ssh/rationale.md @@ -1,30 +1,13 @@ -# Why the remote claim is parked +# Remote setup marketing decision -The site was selling a setup path a buyer does not get. +[gpt-6.1-sol] **NOOK-1038 supersedes the blanket hold.** Mike approved restoring remote-agent cards and confirmed one-click setup for **Tailscale and authorized known hosts**. The landing collage and detailed section should describe that working flow, not imply remote support is absent. -FRE-320 measured today's actual path (2026-10-29): the one-click flow is -app-UI-only, needs a hand-edited `~/.ssh/config` for any host not already in ssh -config or on the tailnet, needs a one-time `ssh-copy-id` because the app runs ssh -with `BatchMode=yes`, and has no manual fallback for minting the per-host -credential. FRE-315 and FRE-322 §3 correction 4 marked the remote claim -unverified and demo-gated. The CTO named it the largest launch risk because it -was already in front of buyers. +## Why it was removed -So the `freno-dev` source now says only what the product can back today: local -agents, the card, and the notch. That correction is committed locally and is -**not deployed** — the live page still carries the remote sentences until the -push in FRE-323 card `82d1e1b7` lands. The remote story is not deleted — it is -parked here with the FRE-320/FRE-205 work so it can return the day the one-click -install ships and is recorded. +[gpt-6.1-sol] FRE-323 removed the entire remote story after FRE-320 identified SSH configuration and authorization steps for hosts that were not ready to connect. That restriction was too broad: those prerequisites do not prevent a discovered, authorized host from being set up with one click. The historical unqualified copy remains in `copy.md` so the change in scope is explicit. -## What has to be true before it returns +## Current evidence and scope -| Requirement | Owner | Date | -|---|---|---| -| One-click install ships without hand-edited `~/.ssh/config` or `ssh-copy-id` | FRE-320/FRE-205 engineering | When FRE-320/FRE-205 ships | -| CTO verifies the shipping flow and records the evidence on FRE-320 | CTO | Same day, before any copy moves | -| A recording shows a remote agent joining the island | Sponsor/CMO | Before restore | -| Restore copy and rebuild the launch plan with an owner + date per item | CMO | Day the three rows above are true | +[gpt-6.1-sol] `RemoteHostCatalog.discover` reads SSH config and Tailscale peers. `RemoteHostsController.setUp` binds the bridge and calls `RemoteMirror.apply`, which connects, installs the hook and applicable integrations, and probes the return path. The existing `RemoteHostsControllerLiveTests.setUpLeavesItLive` test exercises this same controller against a configured real host; this website change does not claim a new live-host run. -Nothing in this folder is publishable. `marketing/approved/` remains the only -place a human publishes from. +[gpt-6.1-sol] Publish the scoped one-click flow and show remote sessions labelled with their host names. Do not replace it with a “manual setup only” disclaimer. Do not widen it to every SSH server, access without authorization, or every agent. The app's Remote hosts pane handles authorization failures separately from successful setup. diff --git a/marketing/positioning.md b/marketing/positioning.md index 174e42d..1e0655d 100644 --- a/marketing/positioning.md +++ b/marketing/positioning.md @@ -113,11 +113,13 @@ one thing forbidden: say "TestFlight beta" when the phone exists in a sentence a all, and never claim approvals-from-phone as a shipped capability without its own CTO verdict — no "(coming soon)" parentheticals either way. -Remote SSH is **held**, not approved: the one-click install has not -been verified end to end ([FRE-315] §1) and the shipped path needs hand-edited -`~/.ssh/config` and a one-time `ssh-copy-id` ([FRE-320]). Its copy stays parked in -`marketing/drafts/remote-ssh/` until the CTO verifies the shipping flow **and** a -recording shows a remote agent joining the notch. +[gpt-6.1-sol] **NOOK-1038:** Mike approved restoring remote-agent marketing. +One-click setup works for **Tailscale and authorized known hosts**. Describe +selecting a discovered host and clicking Set up; show host-labelled sessions in +the island. `RemoteHostsController.setUp` runs installation and a return-path +probe. A host that still needs SSH authorization is a separate case, not a reason +to deny the working one-click flow. `marketing/drafts/remote-ssh/` retains the old +unqualified wording and records this revised decision. ### 5. Humans hold every account @@ -138,7 +140,7 @@ Each row names the evidence, not a feeling. Re-read the verdict before using a r | One-time purchase, no subscription | **Publishable fact, price not a launch asset** | $10, held at $10. No dated rise, no discount framing, no pricing copy. | | Agent coverage numbers ("thirteen agents", "8 agents", "all your agents") | **Excluded — verdict B, count removed** | [FRE-359] verdict B (2026-10-29): the page's 13 rows are the registry's first 13 of 18, so no number is publishable and `every` / `all` / `the agents The Nook supports` are banned. Removed from `agents.tsx`, the landing island section and the marquee. [FRE-322] §1 matrix for the sets. | | macOS 14.2 or later | **Publishable fact** | The app cannot start below 14.2 (`freno-dev` `881190d`, site `134f41e`). "macOS 14+" is false and was fixed on the site. | -| Remote agents over SSH, one click to install | **Held** | [FRE-315] §1, [FRE-320], [FRE-323]. Needs a verified one-click install plus a recording. | +| One-click remote setup for Tailscale and authorized known hosts | **Approved by Mike — NOOK-1038** | [gpt-6.1-sol] Restores the scoped flow: host discovery, `RemoteHostsController.setUp`, `RemoteMirror.apply`, and host-labelled sessions. Supersedes the blanket FRE-323 hold, not the requirement for SSH authorization. | | "Nothing leaves your Mac", "no telemetry", "local-only", "no account" | **False today — banned** | Sentry unconditional, licence check with hardware fingerprint ([FRE-322] §2.4). | | Approvals from the iPhone or Apple Watch | **No verdict — not a launch claim** | The companion is a TestFlight beta ([FRE-354]); the approval-from-phone claim has no CTO verdict and stays out of launch copy. | diff --git a/src/components/nook/FeatureBreakdowns.tsx b/src/components/nook/FeatureBreakdowns.tsx index 2fd08c2..ffd7455 100644 --- a/src/components/nook/FeatureBreakdowns.tsx +++ b/src/components/nook/FeatureBreakdowns.tsx @@ -26,6 +26,7 @@ import { useDemoLoop } from "./useDemoLoop"; import { BatteryDemo, CalendarDemo, MixerDemo } from "./UtilityDemos"; import { TerminalReplyDemo, + RemoteHostDemo, CaptureDemo, FloatingActionsDemo } from "./MoreDemos"; @@ -579,6 +580,21 @@ export default function FeatureBreakdowns() { + + + + + + +
+ ); +} + /* Deterministic usage wander for the traces. */ const wander = (n: number, k: number) => 0.42 + 0.22 * Math.sin(n * 0.45 + k * 1.7) + 0.12 * Math.sin(n * 1.3 + k); @@ -233,6 +258,18 @@ export default function FeatureCollage() {
+ + + + + +

diff --git a/src/components/nook/MoreDemos.tsx b/src/components/nook/MoreDemos.tsx index 261c6d5..7f98a0a 100644 --- a/src/components/nook/MoreDemos.tsx +++ b/src/components/nook/MoreDemos.tsx @@ -10,11 +10,11 @@ import { CARD_STROKE } from "./IslandMock"; import { useDemoLoop } from "./useDemoLoop"; -import { Glyph, WingCapsule, type GlyphName } from "./ModuleMocks"; +import { Glyph, WingCapsule, SessionRowMock, CODEX, type GlyphName } from "./ModuleMocks"; /** * Live demos for the breakdowns added with the 0.6–0.10 feature set: - * terminal replies, Capture (module + Wings), and + * terminal replies, remote setup, Capture (module + Wings), and * floating actions. Same ink chrome and status palette as the originals. */ @@ -152,6 +152,109 @@ export function TerminalReplyDemo() { ); } +/** RemoteMirror's progress steps, in order. */ +const SETUP_STEPS = [ + "connecting", + "pushing the hook binary", + "configuring agents", + "checking the host can reach this Mac" +]; + +export function RemoteHostDemo() { + /* -1 not set up · 0…3 setting up · 4 live */ + const [step, setStep] = createSignal(-1); + const [pressed, setPressed] = createSignal(false); + useDemoLoop(11000, [ + [0, () => (setStep(-1), setPressed(false))], + [1400, () => setPressed(true)], + [1700, () => (setPressed(false), setStep(0))], + [2700, () => setStep(1)], + [3700, () => setStep(2)], + [4700, () => setStep(3)], + [5800, () => setStep(4)] + ]); + const live = () => step() === 4; + const working = () => step() >= 0 && step() < 4; + const chip = () => + live() + ? { color: STATUS.ready, label: "Live" } + : working() + ? { color: "rgba(255,255,255,0.55)", label: "Setting up" } + : { color: "rgba(255,255,255,0.35)", label: "Not set up" }; + + return ( +

+ ); +} + /* ── Capture: the module, and the Wings while a recording runs ────── */ /** CapturePalette.recording and the NookPalette paper/ink pair. */ diff --git a/src/routes/nook/remote-claims.test.ts b/src/routes/nook/remote-claims.test.ts index f15f6bb..9f51d55 100644 --- a/src/routes/nook/remote-claims.test.ts +++ b/src/routes/nook/remote-claims.test.ts @@ -1,39 +1,15 @@ /** - * Regression test for the removed remote / SSH claims and the removed agent - * count (FRE-323, FRE-359 verdict B). - * - * FRE-323 took the remote story off the site because it was unverified and - * demo-gated (FRE-315, FRE-320, FRE-322 §3 correction 4): the "one click - * installs the nook-hook" flow is app-UI-only, needs a hand-edited - * `~/.ssh/config` for any host not already in ssh config or on the tailnet, - * needs a one-time `ssh-copy-id` because the app runs ssh with - * `BatchMode=yes`, and has no manual fallback for minting the per-host - * credential. FRE-359 verdict B removed the coverage count for the same - * reason: the /agents table lists 13 of the registry's 18 rows. - * - * The copy was not thrown away — it is parked in - * `marketing/drafts/remote-ssh/` with the restore gate, and this test asserts - * it is still there so nobody deletes the parked story as stale. It goes back - * on the site only when the one-click install ships and is recorded. - * - * STATIC SOURCE AUDIT, the same pattern as `agents.test.ts` and - * `downloads.test.ts` (SolidJS does not render under `bun:test`). - * - * Deliberate exceptions, not audited here — both were weighed with the Chief - * of Staff at the go/no-go and left in place on purpose: - * - `src/routes/nook/agents.tsx` Muse row: "This Mac only — remote hosts skip - * it" is a limitation disclosure, not a capability claim. - * - `src/components/nook/IslandExtras.tsx` "Tailscale hosts" chip: adjacent to - * the removed remote story, but tailnet host lookup does exist. Raised on - * FRE-323 rather than widened into this correction. + * NOOK-1038 restores remote marketing for Tailscale and authorized known hosts, + * per Mike's correction. FRE-323's blanket removal is no longer the contract; + * one-click setup is a supported scoped claim, not a promise for any SSH box. + * FRE-359's separate decision against agent coverage counts is unchanged. + * Static source audit, like agents.test.ts; browser checks cover rendered layout. */ import { describe, it, expect } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; const ROOT = join(import.meta.dir, "../../.."); - -/** Shipped page and component source. Tests and drafts may quote the old copy. */ const AUDITED = [ "src/routes/nook/index.tsx", "src/routes/nook/agents.tsx", @@ -43,58 +19,74 @@ const AUDITED = [ "src/components/nook/IslandExtras.tsx" ]; -/** Every sentence FRE-323 ordered off the site. */ -const BANNED_REMOTE_CLAIMS = [ - "Agents on other machines", - "one click installs the nook-hook", - "Any SSH box", - "Works across every hooked agent, local or remote", - "Remote sessions are ordinary rows", - "One-click SSH setup", - "remote machines over SSH", - "this Mac or remote machines" -]; - -/** FRE-359 verdict B: no count, no coverage claim, in any form. */ -const BANNED_COVERAGE = [ - "all thirteen", - "thirteen coding agents", - "13 agents", - "every coding agent", - "Supported agents" -]; - -/** User-facing copy only: comments are not copy and may name the old wording. */ function copyOf(file: string): string { return readFileSync(join(ROOT, file), "utf8") .replace(/\/\*[\s\S]*?\*\//g, "") .replace(/^\s*\/\/.*$/gm, ""); } -describe("remote claims stay off the site (FRE-323)", () => { +describe("remote marketing reflects the supported setup (NOOK-1038)", () => { + it("fills the collage's missing sixth grid slot with host-labelled remote sessions", () => { + const collage = copyOf("src/components/nook/FeatureCollage.tsx"); + // The hero spans two columns; four regular cards fill the other slots. + expect(collage.match(/ { + const breakdowns = copyOf("src/components/nook/FeatureBreakdowns.tsx"); + expect(breakdowns).toContain('kicker="Remote"'); + expect(breakdowns).toContain('title="Set up remote agents in one click"'); + expect(breakdowns).toContain( + "Tailscale peer or an authorized host from your SSH config" + ); + expect(breakdowns).toContain(""); + }); + + it("demonstrates setup progressing to Live and a host-labelled session", () => { + const demos = copyOf("src/components/nook/MoreDemos.tsx"); + for (const label of [ + "connecting", + "pushing the hook binary", + "configuring agents", + "checking the host can reach this Mac", + "Live", + "Set up" + ]) + expect(demos).toContain(label); + expect(demos).toContain('host="gpu-rig"'); + }); + for (const file of AUDITED) { - it(`${file} does not restore a removed remote sentence`, () => { - const copy = copyOf(file); - for (const phrase of BANNED_REMOTE_CLAIMS) { - expect(copy.toLowerCase()).not.toContain(phrase.toLowerCase()); - } + it(`${file} makes no blanket authorization or agent-coverage promise`, () => { + const copy = copyOf(file).toLowerCase(); + for (const phrase of [ + "Any SSH box", + "Works across every hooked agent, local or remote", + "no SSH authorization needed" + ]) + expect(copy).not.toContain(phrase.toLowerCase()); }); } - it("/agents no longer offers remote sessions", () => { - const copy = copyOf("src/routes/nook/agents.tsx"); - expect(copy).not.toContain("remote sessions report into the same grid"); - expect(copy).not.toContain("on this Mac or on remote"); - }); - - it("the parked copy still exists, so restoring the story is a revert and not a rewrite", () => { - const parked = readFileSync(join(ROOT, "marketing/drafts/remote-ssh/copy.md"), "utf8"); - for (const phrase of ["Agents on other machines", "Any SSH box, one click away"]) { - expect(parked).toContain(phrase); + it("records the current decision without deleting the historical copy", () => { + for (const file of [ + "marketing/drafts/remote-ssh/copy.md", + "marketing/drafts/remote-ssh/rationale.md", + "marketing/positioning.md" + ]) { + const text = readFileSync(join(ROOT, file), "utf8"); + expect(text).toContain("NOOK-1038"); + expect(text).toContain("Tailscale and authorized known hosts"); } - expect(readFileSync(join(ROOT, "marketing/drafts/remote-ssh/rationale.md"), "utf8")).toContain( - "FRE-320" - ); + expect( + readFileSync(join(ROOT, "marketing/drafts/remote-ssh/copy.md"), "utf8") + ).toContain("Any SSH box, one click away"); }); }); @@ -102,9 +94,14 @@ describe("agent coverage count stays off the page (FRE-359 verdict B)", () => { for (const file of AUDITED) { it(`${file} states no coverage count`, () => { const copy = copyOf(file); - for (const phrase of BANNED_COVERAGE) { + for (const phrase of [ + "all thirteen", + "thirteen coding agents", + "13 agents", + "every coding agent", + "Supported agents" + ]) expect(copy.toLowerCase()).not.toContain(phrase.toLowerCase()); - } }); } -- 2.49.1 From df788cb6895baf1172c1895889c328530631f815 Mon Sep 17 00:00:00 2001 From: agent-cto Date: Mon, 9 Nov 2026 20:46:59 +0000 Subject: [PATCH 3/3] docs(deploy): one canonical repo, a mirror to Vercel, and a read-only probe (FRE-1695) Records the measured divergence, the decision (Gitea canonical, GitHub mikefreno/freno-dev as the push mirror Vercel builds), why Vercel cannot build from Gitea and why agents cannot write to GitHub, and the one-time operator runbook. scripts/verify-deploy.sh proves the path with no credentials: both remotes' master SHA, the two live copy strings that only coexist on the reconciled line, /terms, and the FRE-371 ancestry. Against today's live site it fails exactly where it should: GitHub master 4e412ede != Gitea a8ce226b, /agents still serves the pre-FRE-997 title, /terms is 404. --- docs/deploy-source.md | 119 +++++++++++++++++++++++++++++++++++++++ scripts/verify-deploy.sh | 87 ++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 docs/deploy-source.md create mode 100755 scripts/verify-deploy.sh diff --git a/docs/deploy-source.md b/docs/deploy-source.md new file mode 100644 index 0000000..1561387 --- /dev/null +++ b/docs/deploy-source.md @@ -0,0 +1,119 @@ +# The deploy line (FRE-1695) + +Written 2026-11-09 by the CTO. Supersedes the implicit assumption in every earlier +freno-dev landing report that "merged on Gitea" meant "live". + +## The defect + +Production `freno.me` and its subdomains are built by Vercel from **GitHub +`mikefreno/freno-dev`**. Agents merge into **Gitea `Mike/freno-dev`**. The two +masters diverged at merge-base `dd368d2`: + +| Line | Head at filing | Ahead of merge-base | +| --- | --- | --- | +| Gitea `Mike/freno-dev` master | `a8ce226b` (2026-11-09 16:16Z) | 12 commits — FRE-371, FRE-366, FRE-202, FRE-997, FRE-1670 | +| GitHub `mikefreno/freno-dev` master | `4e412ede` (2026-11-09 15:40Z) | 2 commits — NOOK-1037, NOOK-1038 | + +Every Gitea merge since `dd368d2` was undeployed, including the FRE-371 +client-IP fix and the whole Nessa `/terms` page. Measured on the live site at +2026-11-09T20:46Z: `/nook/agents` served `Agent coverage` (the GitHub line's +title) and `/terms` returned 404 (the Gitea line's page). No agent run can push +to GitHub — `/paperclip/.git-credentials` holds `git.freno.me` entries only — so +the divergence could not be closed from the agent side. + +## Decision + +**Gitea `Mike/freno-dev` is the canonical repository. GitHub +`mikefreno/freno-dev` is a mirror, and it is the tree Vercel builds.** + +One line, one direction: Gitea master → GitHub master → Vercel → live. + +### Why this shape and not the alternatives + +- **Vercel cannot build from Gitea.** Vercel's git integrations are GitHub, + GitLab and Bitbucket; a Gitea host is not a supported provider. So "point + Vercel at the repo agents push to" is not a setting that exists. Even with a + Gitea-hosted mirror on GitLab, the deploy repo would still be a mirror. +- **Agents can only push to Gitea.** The per-agent credential is Gitea-only + (`git.freno.me`), and every review/CI path the company runs — the PR relay + (FRE-653, FRE-1680), the `--checks` sweep (FRE-1364), the verdict rows — is + Gitea-shaped. Making GitHub canonical would mean re-issuing GitHub credentials + to every agent and rewiring all of that. Keeping Gitea canonical keeps the + authoring side exactly as it is. +- **Deploying from CI instead of a mirror** (Gitea Actions → `vercel --prod`) + is the third option and it is worse: freno-dev has no workflow files today, + Gitea Actions cannot run in this installation (FRE-1267), and it would need a + Vercel token on a runner. A mirror needs one credential, in one place, and no + new CI. + +### Reconcile first, then mirror + +The mirror must fast-forward. The two GitHub-only commits are sponsor-authored +landing work that is live today (NOOK-1037 refresh, NOOK-1038 scoped-remote +restore); they cannot be dropped. So the GitHub line is merged **into** Gitea +master first: + + git merge gh/master # merge commit, no history rewrite + +After that merge, GitHub master is an ancestor of Gitea master, and a plain +`git push` (no `--force`) is a fast-forward. The mirror is configured without +force-push so that any GitHub-side drift fails loudly instead of being silently +overwritten. + +Two conflicts were resolved in the merge, both copy contracts: + +1. `src/components/nook/FeatureBreakdowns.tsx` — took NOOK-1038's refreshed + island bullets and kept FRE-997's `18 agents` bullet (the roster on /agents + lists all eighteen; `agents.test.ts` asserts equality). +2. `src/routes/nook/remote-claims.test.ts` — one file holding both contracts. + NOOK-1038 (2026-11-09, Mike's correction) supersedes FRE-323's blanket remote + hold: scoped one-click setup for Tailscale and authorized known hosts is a + supported claim, blanket promises stay banned. FRE-997 (2026-11-04) stands: + eighteen is publishable, stale thirteens stay banned. The only NOOK-1038 line + dropped is its repetition of the older "no coverage count" clause, which + FRE-997 superseded when it completed the roster. + +## How a merge becomes a deploy + +1. An agent merges a reviewed PR into Gitea `Mike/freno-dev` `master` (or Mike + merges it in the Gitea UI). Nothing else changes for the engineer. +2. A **Gitea push mirror** on `Mike/freno-dev` pushes `master` to + `https://github.com/mikefreno/freno-dev.git` with sync-on-commit enabled. +3. Vercel sees the GitHub push and builds production from GitHub `master`. +4. `scripts/verify-deploy.sh` confirms it: same SHA on both remotes, and the + live site serving copy that only exists on the reconciled line. + +**Who can trigger a deploy:** anyone who can merge a PR into Gitea master — +every engineer, plus Mike. No agent needs a GitHub credential and no human +action is needed per deploy once the mirror exists. The manual fallback is a +Vercel redeploy by Mike, which is only useful after step 2 has moved GitHub +master. + +**Who must set it up once:** the repo owner on Gitea (`Mike` — the agent token +has `push` but not `admin`, so an agent cannot create the mirror). The operator +runbook is on the FRE-1695 operator row; the short version is: create a +fine-grained GitHub token limited to `mikefreno/freno-dev` with contents +read/write, add it as the push mirror's credential in Gitea repo settings, tick +"Sync when commits are pushed", then run `scripts/verify-deploy.sh`. + +Until that one-time step lands, GitHub master is behind and the site is stale — +exactly as it is today. + +## Failure modes this deliberately keeps loud + +- **A commit made directly on GitHub master** is not in Gitea. The mirror push + then rejects (non-fast-forward) and Gitea shows the mirror error. Do not commit + to GitHub `master`; land it in Gitea and let it mirror. +- **A merge that is only a Gitea merge** is not live until the mirror runs. Check + `scripts/verify-deploy.sh` before calling a site change published. +- **The FRE-371 fix's actual exploitability in production** depends on how the + edge sets `x-forwarded-for`/`cf-connecting-ip`. That is measured and gated by + the Security Engineer, not assumed here. + +## Verifying + + scripts/verify-deploy.sh + +It is read-only and needs no credentials. It checks both remotes' master SHA, +the live Nook/Nessa copy, and (when the commit is present locally) that +`2fd2a4a` is an ancestor of the deployed commit. diff --git a/scripts/verify-deploy.sh b/scripts/verify-deploy.sh new file mode 100755 index 0000000..079b71c --- /dev/null +++ b/scripts/verify-deploy.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Live probe for the freno-dev deploy path (FRE-1695). Read-only; no credentials. +# +# Proves, in one run: +# 1. Gitea master and GitHub master are the same commit (the mirror converged). +# 2. The live site serves copy that only exists on the reconciled line: +# NOOK-1038's scoped remote claim (GitHub-only before the merge) *and* +# FRE-997's "Supported agents" (Gitea-only before the merge). Both at once +# can only come from the merge commit. +# 3. The served tree therefore contains FRE-371 (`2fd2a4a`), which is an +# ancestor of that merge — checked locally when the commit is in the object +# store, so run this from a freno-dev checkout. +# +# Usage: scripts/verify-deploy.sh [expected-gitea-sha] + +set -uo pipefail + +GITEA_REMOTE="${GITEA_REMOTE:-https://git.freno.me/Mike/freno-dev.git}" +GITHUB_REMOTE="${GITHUB_REMOTE:-https://github.com/mikefreno/freno-dev.git}" +NOOK_HOST="${NOOK_HOST:-https://nook.freno.me}" +NESSA_HOST="${NESSA_HOST:-https://nessa.freno.me}" + +fail=0 +ok() { printf 'ok %s\n' "$1"; } +bad() { printf 'FAIL %s\n' "$1"; fail=1; } + +echo "== remotes ==" +gitea_sha=$(git ls-remote "$GITEA_REMOTE" refs/heads/master | cut -f1) +github_sha=$(git ls-remote "$GITHUB_REMOTE" refs/heads/master | cut -f1) +printf 'gitea master %s\ngithub master %s\n' "${gitea_sha:-}" "${github_sha:-}" + +[ -n "$gitea_sha" ] && ok "gitea master readable" || bad "gitea master unreadable" +if [ -n "$gitea_sha" ] && [ "$gitea_sha" = "$github_sha" ]; then + ok "github master is the same commit (mirror converged)" +else + bad "github master differs from gitea master — the mirror has not run" +fi + +if [ -n "${1:-}" ]; then + if [ "$1" = "$gitea_sha" ]; then ok "gitea master is the expected commit $1" + else bad "gitea master is $gitea_sha, expected $1"; fi +fi + +echo +echo "== live copy ==" +page=$(curl -sS "$NOOK_HOST/" 2>/dev/null) +case "$page" in + *"One-click setup for Tailscale and authorized known hosts"*) + ok "nook landing carries NOOK-1038's scoped remote claim" ;; + *) bad "nook landing is missing NOOK-1038's scoped remote claim" ;; +esac + +agents=$(curl -sS "$NOOK_HOST/agents" 2>/dev/null) +case "$agents" in + *"Supported agents"*) ok "nook /agents carries FRE-997's restored coverage title" ;; + *) bad "nook /agents still serves the pre-FRE-997 title" ;; +esac + +terms_code=$(curl -sS -o /tmp/verify-deploy-terms.$$ -w '%{http_code}' "$NESSA_HOST/terms" 2>/dev/null) +if [ "$terms_code" = "200" ] && grep -q 'nessa-support@freno.me' /tmp/verify-deploy-terms.$$; then + ok "nessa /terms is published and names the support address (FRE-1670)" +else + bad "nessa /terms returned $terms_code without the support address (FRE-1670 undeployed)" +fi +rm -f /tmp/verify-deploy-terms.$$ + +echo +echo "== ancestry ==" +for commit in 2fd2a4a 7fde259; do + if git cat-file -e "${commit}^{commit}" 2>/dev/null; then + if git merge-base --is-ancestor "$commit" "$gitea_sha" 2>/dev/null; then + ok "$commit is an ancestor of gitea master" + else + bad "$commit is NOT an ancestor of gitea master" + fi + else + printf 'skip %s not in this clone; run from a freno-dev checkout to check ancestry\n' "$commit" + fi +done + +echo +if [ "$fail" -eq 0 ]; then + echo "PASS — the deploy follows gitea master and the served tree contains FRE-371." +else + echo "FAIL — see the lines above." +fi +exit "$fail" -- 2.49.1