dl.f3liz.casa/drop を読んでいます
zen-mode
ブラウザの飾りを、ぜんぶ仕舞う(Accel+Alt+Z。上の端に触れているあいだだけ、戻ってくる)
uuid fcb5ba30-981d-4953-b29e-36483f5d0281
install
Press inside noraneko. It catches the link, opens its settings with this uuid, downloads the xpi, checks the sha256 and the registry's signature, and shows you everything below again before anything runs.
about:nora:settings in noraneko instead.
who
gh/nyanruswhat is served
zen-mode · zen-mode@noraneko.app · version 1.2.0 · 29424 bytes sha256 3fb5beb5e72d18bf90e3d0ebb32b5727c66fd6ad16b33d6e65510a2a85fc25e8 built from drops/zen-mode/src @ 59b96c854c (2026-09-14T07:24:00Z) served at https://dl.f3liz.casa/drop/fcb5ba30-981d-4953-b29e-36483f5d0281/in the registry
drops/zen-mode/ @ 59b96c854c — この版が出てきた木 actors: zen-modethe source, as it is
These come from source.json next to the manifest — the same text the xpi carries under source/, and dl checks its sha256 against the signed manifest before handing it over. Not a copy of the repository: what you read and what runs came out of the same build. 4 files; press one to open it.
_shared/defineActor.ts · 118 lines
// SPDX-License-Identifier: MPL-2.0
// Authoring surface for an "actor": a small xpi whose page side is a
// JSWindowActor (the same shape as Firefox's own about:newtab add-on). Each
// actor lives in one file that exports three things by name:
//
// export const meta = { id, namespace, matches, ... };
// export const parent = defineParent({ method(...) { ... } }); // main process (JSWindowActorParent)
// export const content = defineContent((parent, ctx) => { ... }); // page side (run by JSWindowActorChild)
//
// The page side is NOT a WebExtension content script: content scripts are not
// injected into about:* / chrome:* documents in stock Firefox (see README,
// "addon 式が駄目だった理由"). The child actor loads content.js into the page's
// process with `window` / `document` on the scope chain, so the hook is written
// the same way, but it runs with the child actor's privileges.
//
// Named exports (rather than one nested object) are deliberate: the build
// bundles `parent` and `content` into separate outputs, and named exports let
// the bundler tree-shake each side so the content bundle never drags in the
// parent's privileged code and vice versa.
//
// IMPORTANT: the module top level must be pure. Reference Firefox globals
// (Services, ChromeUtils, window, exportFunction, browser) only *inside* method
// or hook bodies — the build imports this file under Deno to read `meta`, and
// top-level use of those globals would throw there.
export interface ActorMeta {
/** Extension id, e.g. "settings-bridge@noraneko.app". */
id: string;
/** Extension version. */
version: string;
/** Experiment-API namespace, e.g. "noraSettings". Also the message channel. */
namespace: string;
/** Match patterns for the pages the content hook runs in (JSWindowActor `matches`). */
matches: string[];
/**
* When the content hook runs. Maps to a child actor event:
* document_start → DOMDocElementInserted, document_end → DOMContentLoaded,
* document_idle → load. Defaults to "document_end".
*/
runAt?: "document_start" | "document_end" | "document_idle";
/**
* JSWindowActor name. Defaults to "Nora" + PascalCase(<dir>), e.g. NoraNewtab.
* A drop with the same actor name replaces the built-in one (unregister → register).
*/
actor?: string;
/** Legacy JSActor (BrowserGlue) this one replaces; it is unregistered when this actor is registered. */
replaces?: string;
}
import type { Io } from "./io.ts";
type ParentMethods = Record<string, (...args: any[]) => unknown>;
export interface ContentCtx {
/** True in dev builds (import.meta.env.MODE === "dev"). */
dev: boolean;
/** Expose functions on the page window (via exportFunction). */
expose(funcs: Record<string, (...args: any[]) => unknown>): void;
/**
* Run when this actor goes away in this window (the drop is removed or
* replaced). Put back what the hook changed: DOM, observers, listeners.
*/
onDestroy(fn: () => void): void;
/**
* Place things through these (DOM, styles, listeners, pref observers) and
* they are taken out again by themselves: each verb puts its way back on
* the same ledger as onDestroy.
*/
io: Io;
/** This actor's own files: `${ctx.base}ops/x.tsubaki` and the like. */
base: string;
/**
* The actor's logic in Tsubaki (when it ships wasm/): run a .tsubaki file of
* its own with load("ops/x.tsubaki"), then call(name, ...args). Values cross
* as ordinary JS values; a Tsubaki error is a JS Error. undefined without wasm/.
*
* Every verb answers with a Promise, because for an actor of the browser
* WINDOW the logic lives a process away (wasm can't compile in the parent
* process, so it runs in a hidden page of this window instead). A drop never
* has to know which side it got.
*/
ops: Ops | undefined;
}
export interface Ops {
ready: Promise<void>;
call(name: string, ...args: unknown[]): Promise<unknown>;
}
/** Proxy to the parent methods; each call is forwarded to the main process. */
type ParentProxy<T extends ParentMethods> = {
[K in keyof T]: (...args: Parameters<T[K]>) => Promise<Awaited<ReturnType<T[K]>>>;
};
export type ContentHook<T extends ParentMethods = ParentMethods> = (
parent: ParentProxy<T>,
ctx: ContentCtx,
) => void;
// Identity helpers that pin types for authoring:
//
// export const parent = defineParent({ method(...) { ... } });
// export const content = defineContent<typeof parent>((parent, ctx) => { ... });
//
// They are listed in the build's treeshake.manualPureFunctions, so an unused
// call (e.g. defineContent in the parent bundle) is dropped along with anything
// it references (birpc). Without that, the bundler would keep the call for its
// possible side effects and the unused side would not tree-shake.
/** Pins the parent-method types so the content hook's proxy is typed. */
export const defineParent = <T extends ParentMethods>(methods: T): T => methods;
/** Pins the content hook against the parent's method types. */
export const defineContent = <T extends ParentMethods>(
hook: ContentHook<T>,
): ContentHook<T> => hook;
zen-mode/actor.ts · 29 lines
// SPDX-License-Identifier: MPL-2.0
// GENERATED by build.ts. この drop の actor は Tsubaki で書かれている:
// 直すのは ops/Style.tsubaki / ops/zen-mode.tsubaki と drop.toml の [actor]。
//
// 殻は lib の std-actor。logic が答える三つの door(setup / start / dispatch)
// を呼んで、返ってきた view を描き、effects を carry out する。殻の bytes は
// その lib に一枚だけあって、ここには入らない(この drop に残るのは、下の宣言だけ)。
import { defineContent, defineParent, type ActorMeta } from "../_shared/defineActor.ts";
import { runTsubakiActor } from "std-actor";
export const meta: ActorMeta = {
"id": "zen-mode@noraneko.app",
"version": "1.2.0",
"namespace": "noraZenMode",
"matches": [
"chrome://browser/content/browser.xhtml"
],
"runAt": "document_end"
};
export const parent = defineParent({});
export const content = defineContent<typeof parent>((_parent, ctx) => {
runTsubakiActor(ctx, {"prefs":["floorp.zenmode.enabled"],"keys":["Accel+Alt+Z"],"commands":[],"browserPages":[],"menus":[],"tabs":"","tabMarks":false,"tabValues":false,"windowValues":false,"prompt":false,"currentUrl":false,"openUrl":false,"webFrame":false,"chromeStyle":true,"ownPrefix":"noraneko.zen-mode.","name":"zen-mode","uuid":"fcb5ba30-981d-4953-b29e-36483f5d0281","strings":{}}).catch((e) =>
console.error("[zen-mode] failed:", e)
);
});
zen-mode/ops/Style.tsubaki · 66 lines
# SPDX-License-Identifier: MPL-2.0
#
# zen-mode の一枚。ここは **生の CSS**(`setup().style`)で、drop.toml の
# chrome_style がその宣言 -- 自分が置いたものだけでなく、ブラウザの飾りそのものを
# 触るので。
#
# 仕舞うことも、上の端で戻ってくることも、ぜんぶ CSS の中で閉じている。
# `:root:has(#nora-zen-edge)` -- あの細い帯が居るあいだが、zen のあいだ。
# logic は「帯を置くか置かないか」だけを決めていて、hover のことは知らない。
#
# Floorp は同じことを 271 行の TypeScript でしていた(端からの距離を測って、
# 属性を五つ付け外しして、500ms 待って戻す)。こちらは待ち時間も状態も持たない
# -- `:hover` が、もうそれを持っているので。
#
# (Tsubaki に複数行の文字列はまだ無いので、規則を繋いでいる。一行が一つの規則。)
module Style
# 仕舞うときの動き。字で三度書かないように、名前をつけて置いておく
gone() = "transform: translateY(-100%); opacity: 0; pointer-events: none;"
back() = "transform: none; opacity: 1; pointer-events: auto;"
sheet() = (
# 置き場所。host は流れの外に立たせる(#browser の並びを動かさないため)
"#nora-zen { position: fixed; top: 0; left: 0; width: 0; height: 0; }" *
# 上の端の、細い帯。zen のあいだだけ居る。
# 窓を掴んで動かせるのは Floorp と同じ(#browser::before がしていたこと)。
# z-index は toolbox(101)より下 -- 出てきたら、帯より toolbar が上に立つ。
"#nora-zen-edge { position: fixed; top: 0; left: 0; right: 0; height: 8px;" *
" z-index: 100; -moz-window-dragging: drag; }" *
# --- 仕舞う ---------------------------------------------------------
# toolbox は「自分のいまの高さ」ぶん退く(絶対配置にしてから動かすので、
# 出入りで中身が組み直されない。高さを測って覚える必要も無い)
":root:has(#nora-zen-edge) #navigator-toolbox { position: absolute;" *
" top: 0; left: 0; right: 0; z-index: 101;" *
" " * gone() *
" transition: transform .3s cubic-bezier(.4, 0, .2, 1), opacity .25s ease; }" *
# urlbar は top layer に出るので、toolbox と一緒には退かない
":root:has(#nora-zen-edge) #urlbar { opacity: 0; pointer-events: none;" *
" transition: opacity .15s ease; }" *
# Firefox 自身のサイドバー。他の drop が置いたものには触らない
# -- この drop が知っているのは、ブラウザの飾りだけ
":root:has(#nora-zen-edge) #sidebar-box," *
":root:has(#nora-zen-edge) #sidebar-splitter," *
":root:has(#nora-zen-edge) #sidebar-launcher-splitter," *
":root:has(#nora-zen-edge) sidebar-main { display: none; }" *
# --- 戻ってくる -----------------------------------------------------
# 端の帯に触れているあいだと、出てきた toolbar に触れているあいだ。
# 帯から toolbar へ指が移る瞬間に途切れないのは、二つ目の行があるから
":root:has(#nora-zen-edge:hover) #navigator-toolbox," *
":root:has(#nora-zen-edge):has(#navigator-toolbox:hover) #navigator-toolbox { " * back() * " }" *
":root:has(#nora-zen-edge:hover) #urlbar," *
":root:has(#nora-zen-edge):has(#navigator-toolbox:hover) #urlbar {" *
" opacity: 1; pointer-events: auto; transition-delay: .15s; }" *
"@media (prefers-reduced-motion: reduce) {" *
" :root:has(#nora-zen-edge) #navigator-toolbox," *
" :root:has(#nora-zen-edge) #urlbar { transition-duration: 0s; transition-delay: 0s; } }"
)
end
zen-mode/ops/zen-mode.tsubaki · 123 lines
# SPDX-License-Identifier: MPL-2.0
#
# zen-mode: 飾りを、ぜんぶ仕舞う。
#
# Floorp の zen-mode を、noraneko の語彙で書き直したもの。むこうは 1,129 行で、
# 端からの距離を測り、`:root` に属性を五つ付け外しし、要素の高さを CSS 変数に
# 書き戻し、500ms 待って隠していた。
#
# こちらの logic がしているのは、これだけ:
#
# 鍵が押されたら、pref を裏返す。pref が立っているあいだ、細い帯を一枚置く。
#
# 仕舞うのも、上の端に触れたら戻ってくるのも、Style.tsubaki の中で閉じている
# (`:root:has(#nora-zen-edge)` と `:hover`)。**状態は pref 一本だけ** -- 隠れて
# いるかどうかを二か所で覚えない、というのが、この drop でいちばん大事なところ。
#
# 殻が訊いてくるのは三つ:
#
# setup() どこに置くか、style、見ていてほしい pref
# start(facts) 最初の一枚(facts = もう本当だったこと)
# dispatch(action) 次の一枚と、してほしいこと
#
# まだ無いもの: メニューの行(押しかたを、右クリックから見つけられない)と、
# 押しかたを変えること。どちらも `at: "menu"` と設定の一枚が来たら、素直に立つ。
PREF_ON = "noraneko.zen-mode.enabled"
FLOORP_ON = "floorp.zenmode.enabled"
# Accel は、どこでも同じ指(Windows と Linux は Ctrl、macOS は Cmd)。
# Floorp の既定と同じ組み合わせ。drop.toml の keys にも、同じ字で並べてある
# -- 入れる人が読む行と、実際に押せる鍵が、同じ一つの字から出る。
COMBO = "Accel+Alt+Z"
import Style
# --- setup --------------------------------------------------------------------
# 置き場所は二つ。帯は #browser の中(流れの外に立つので、並びは動かない)、
# 鍵は窓の keyset の隣 -- <key> は keyset の直の子でないと Firefox が見ない。
setup() = Setup(
anchors = [
Anchor(name = "mark", at = "parent", selector = "#browser", tag = "hbox", id = "nora-zen"),
Anchor(name = "keys", at = "keyset", id = "nora-zen-keys")
],
style = Style.sheet(),
prefs = [PREF_ON, FLOORP_ON]
)
@kwdef struct AppState
on = false
end
STATE = AppState()
# --- view ---------------------------------------------------------------------
# 帯が居るあいだが、zen のあいだ。CSS はこの一枚が居るかどうかだけを見ている。
view(s) = Dict(
"mark" => s.on ? el("box", Dict("id" => "nora-zen-edge")) : nothing,
"keys" => el("key", Dict("combo" => COMBO, "on:command" => Toggle()))
)
# --- actions ------------------------------------------------------------------
struct Toggle end
"about:config で書き換えられたとき、殻が投げてくる"
struct PrefChanged
name
value
end
action_of(a) = begin
kind = get(a, :__type, "")
if kind == "Toggle"
Toggle()
elseif kind == "PrefChanged"
PrefChanged(get(a, :name, ""), get(a, :value, nothing))
else
error("zen-mode: no action named " * kind)
end
end
# --- update -------------------------------------------------------------------
struct Step
state
effects
end
update(s::AppState, a::Toggle) = begin
on = s.on ? false : true
Step(AppState(on = on), [SetPref(PREF_ON, on)])
end
# 自分の pref だけを聞く。Floorp のほうは start で一度読むだけで、以後は見ない
# -- 一度でも自分で決めたなら、そちらが本当のこと。
update(s::AppState, a::PrefChanged) = begin
if a.name == PREF_ON
Step(AppState(on = a.value === true), [])
else
Step(s, [])
end
end
# --- the doors ----------------------------------------------------------------
# 自分の pref がまだ無いときだけ、Floorp のを見る。Floorp で zen に居た人が、
# 入れた日にそのまま zen で始まるように。
function start(facts)
prefs = get(facts, :prefs, Dict())
mine = get(prefs, PREF_ON, nothing)
on = mine === nothing ? get(prefs, FLOORP_ON, false) === true : mine === true
STATE = AppState(on = on)
return frame(view(STATE))
end
function dispatch(a)
st = update(STATE, action_of(a))
STATE = st.state
return frame(view(STATE), st.effects)
end
What a drop is, how it reaches you, and what it is not: f3liz.casa/noraneko/drops/. Served by https://dl.f3liz.casa/drop, signed by https://github.com/f3liz-casa/noraneko-registry/.github/workflows/verify-and-sign.yml@refs/heads/main.
This page is built when you ask for it, out of what https://dl.f3liz.casa/drop is serving right now (2026-09-23T08:38:28.032Z). Drafted by Shiro, an AI assistant, sitting next to nyanrus.