brane 0.1.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +367 -0
- package/CHANGELOG.md +66 -0
- package/CONTRIBUTING.md +101 -0
- package/DESIGN.md +405 -0
- package/Dockerfile +61 -0
- package/LICENSE +656 -17
- package/README.md +174 -2
- package/SECURITY.md +213 -0
- package/dist/absorb/brane-id.js +11 -0
- package/dist/absorb/chatgpt.js +161 -0
- package/dist/absorb/claude-code.js +504 -0
- package/dist/absorb/index.js +543 -0
- package/dist/absorb/organelle.js +345 -0
- package/dist/absorb/run.js +520 -0
- package/dist/absorb/sources.js +168 -0
- package/dist/absorb/types.js +3 -0
- package/dist/agent-env.js +45 -0
- package/dist/agents.js +1168 -0
- package/dist/claude-live-session.js +124 -0
- package/dist/cli.js +50 -0
- package/dist/codex-tool-log.js +144 -0
- package/dist/commands/absorb.js +291 -0
- package/dist/commands/attach.js +31 -0
- package/dist/commands/certify.js +62 -0
- package/dist/commands/config.js +133 -0
- package/dist/commands/cost.js +126 -0
- package/dist/commands/daemon.js +350 -0
- package/dist/commands/doctor.js +262 -0
- package/dist/commands/fold.js +95 -0
- package/dist/commands/list.js +89 -0
- package/dist/commands/mcp.js +84 -0
- package/dist/commands/meld.js +360 -0
- package/dist/commands/memory.js +215 -0
- package/dist/commands/revive.js +74 -0
- package/dist/commands/run.js +85 -0
- package/dist/commands/spawn.js +1101 -0
- package/dist/commands/token.js +154 -0
- package/dist/copilot-tool-log.js +145 -0
- package/dist/cost/pricing.js +116 -0
- package/dist/cost/reprice.js +136 -0
- package/dist/crush-tool-log.js +293 -0
- package/dist/daemon/agent-scope.js +116 -0
- package/dist/daemon/awaiting.js +54 -0
- package/dist/daemon/cert-manager.js +349 -0
- package/dist/daemon/client.js +262 -0
- package/dist/daemon/gated-routes.js +112 -0
- package/dist/daemon/keep-awake.js +103 -0
- package/dist/daemon/local-socket.js +145 -0
- package/dist/daemon/presence-gate.js +206 -0
- package/dist/daemon/server.js +6353 -0
- package/dist/daemon/signal-source.js +35 -0
- package/dist/daemon/tailscale-rebind.js +58 -0
- package/dist/daemon/token-ring.js +263 -0
- package/dist/daemon/token-routes.js +190 -0
- package/dist/db/events.js +152 -0
- package/dist/db/mentions.js +179 -0
- package/dist/db/schema.js +643 -0
- package/dist/db/tokens.js +624 -0
- package/dist/db/tool-uses.js +647 -0
- package/dist/diagram/index.js +85 -0
- package/dist/diagram/label.js +455 -0
- package/dist/diagram/layout.js +476 -0
- package/dist/diagram/links.js +50 -0
- package/dist/diagram/metrics.js +152 -0
- package/dist/diagram/parse.js +487 -0
- package/dist/diagram/svg.js +389 -0
- package/dist/diagram/types.js +9 -0
- package/dist/export.js +629 -0
- package/dist/files/archive-types.js +2 -0
- package/dist/files/archive.js +182 -0
- package/dist/files/browse.js +327 -0
- package/dist/files/bytes.js +162 -0
- package/dist/files/codecs.js +33 -0
- package/dist/files/find.js +212 -0
- package/dist/files/tar.js +223 -0
- package/dist/files/zip.js +209 -0
- package/dist/folds.js +72 -0
- package/dist/grammars/ebnf.js +92 -0
- package/dist/highlight.js +250 -0
- package/dist/ids.js +24 -0
- package/dist/markdown-diagnostics.js +233 -0
- package/dist/math.js +168 -0
- package/dist/mcp/server.js +886 -0
- package/dist/memory/embedder.js +145 -0
- package/dist/memory/embedding-worker.js +71 -0
- package/dist/memory/embeddings.js +91 -0
- package/dist/memory/index.js +232 -0
- package/dist/memory/storage.js +587 -0
- package/dist/memory/wiki.js +688 -0
- package/dist/memory/wordpiece.js +247 -0
- package/dist/mentions-scan.js +40 -0
- package/dist/presence.js +259 -0
- package/dist/session-affinity.js +185 -0
- package/dist/shared/api-request.js +124 -0
- package/dist/shared/app-origin.js +26 -0
- package/dist/shared/block-glyphs.js +114 -0
- package/dist/shared/diagram-css.js +17 -0
- package/dist/shared/export-selection.js +101 -0
- package/dist/shared/find-in-text.js +110 -0
- package/dist/shared/glow-hues.js +225 -0
- package/dist/shared/hex-dump.js +62 -0
- package/dist/shared/hex-pages.js +52 -0
- package/dist/shared/hit-drum.js +79 -0
- package/dist/shared/known-nids.js +58 -0
- package/dist/shared/list-scroll-keys.js +56 -0
- package/dist/shared/markdown-lists.js +72 -0
- package/dist/shared/markdown-math.js +485 -0
- package/dist/shared/markdown-mentions.js +79 -0
- package/dist/shared/markdown-nids.js +41 -0
- package/dist/shared/markdown-protocols.js +53 -0
- package/dist/shared/markdown-refs.js +123 -0
- package/dist/shared/membrane-invasion.js +342 -0
- package/dist/shared/membrane-motion.js +83 -0
- package/dist/shared/membrane-palette.js +253 -0
- package/dist/shared/membrane-terrain.js +212 -0
- package/dist/shared/message-selection.js +123 -0
- package/dist/shared/mo-glyph.js +57 -0
- package/dist/shared/narrative-format.js +237 -0
- package/dist/shared/narrative-menu.js +84 -0
- package/dist/shared/narrative-sort.js +47 -0
- package/dist/shared/narrative-status.js +52 -0
- package/dist/shared/no-pan.js +85 -0
- package/dist/shared/pane-layout.js +50 -0
- package/dist/shared/payload-inline.js +83 -0
- package/dist/shared/speaker-color.js +61 -0
- package/dist/shared/token-rotation.js +14 -0
- package/dist/shared/view-url.js +216 -0
- package/dist/shared/wiki-links.js +70 -0
- package/dist/shared/wiki-window.js +4 -0
- package/dist/tailscale.js +55 -0
- package/dist/test/absorb-claude.test.js +892 -0
- package/dist/test/agent-scope.test.js +193 -0
- package/dist/test/agents.test.js +55 -0
- package/dist/test/api-request.test.js +122 -0
- package/dist/test/app-host-literal.test.js +53 -0
- package/dist/test/awaiting-glow.test.js +54 -0
- package/dist/test/awaiting.test.js +122 -0
- package/dist/test/bind-token.test.js +88 -0
- package/dist/test/block-glyphs.test.js +68 -0
- package/dist/test/cert-manager.test.js +262 -0
- package/dist/test/claude-live-session.test.js +110 -0
- package/dist/test/codex-tool-log.test.js +568 -0
- package/dist/test/config.test.js +71 -0
- package/dist/test/copilot-tool-log.test.js +93 -0
- package/dist/test/cors-preflight.test.js +56 -0
- package/dist/test/cost-linger.test.js +31 -0
- package/dist/test/cost-pricing.test.js +72 -0
- package/dist/test/cost-reprice.test.js +139 -0
- package/dist/test/cost-sessions.test.js +301 -0
- package/dist/test/crush-tool-log.test.js +289 -0
- package/dist/test/daemon-client.test.js +109 -0
- package/dist/test/daemon-socket.test.js +293 -0
- package/dist/test/diagram-arrows.test.js +205 -0
- package/dist/test/diagram-export.test.js +210 -0
- package/dist/test/diagram-labels.test.js +313 -0
- package/dist/test/diagram-parse.test.js +191 -0
- package/dist/test/diagram-render.test.js +175 -0
- package/dist/test/diagram-review.test.js +288 -0
- package/dist/test/diagram-self-loop-labels.test.js +303 -0
- package/dist/test/embedder.test.js +84 -0
- package/dist/test/events.test.js +123 -0
- package/dist/test/export-selection.test.js +102 -0
- package/dist/test/export.test.js +505 -0
- package/dist/test/files-browse.test.js +393 -0
- package/dist/test/files-find.test.js +272 -0
- package/dist/test/find-in-text.test.js +86 -0
- package/dist/test/first-paint-viewport.test.js +59 -0
- package/dist/test/fixtures/embedding-golden.js +23 -0
- package/dist/test/fixtures/wordpiece-golden.js +167 -0
- package/dist/test/glow-hues.test.js +202 -0
- package/dist/test/harness-cost.test.js +423 -0
- package/dist/test/helpers.js +21 -0
- package/dist/test/hex-pages.test.js +68 -0
- package/dist/test/highlight.test.js +69 -0
- package/dist/test/hit-drum.test.js +71 -0
- package/dist/test/hook-config.test.js +299 -0
- package/dist/test/json-gzip.test.js +70 -0
- package/dist/test/key-routing.test.js +58 -0
- package/dist/test/known-nids.test.js +106 -0
- package/dist/test/link-preview.test.js +67 -0
- package/dist/test/list-scroll-keys.test.js +66 -0
- package/dist/test/listen-own-words.test.js +147 -0
- package/dist/test/listen-timeout.test.js +33 -0
- package/dist/test/markdown-diagnostics.test.js +121 -0
- package/dist/test/markdown-lists.test.js +182 -0
- package/dist/test/markdown-math.test.js +256 -0
- package/dist/test/markdown-nids.test.js +146 -0
- package/dist/test/markdown-protocols.test.js +139 -0
- package/dist/test/mcp-bridge-cancel.test.js +98 -0
- package/dist/test/mcp-bridge-reconnect.test.js +121 -0
- package/dist/test/mcp-narrative.test.js +193 -0
- package/dist/test/mcp.test.js +548 -0
- package/dist/test/meld.test.js +143 -0
- package/dist/test/membrane-invasion.test.js +482 -0
- package/dist/test/membrane-motion.test.js +101 -0
- package/dist/test/membrane-palette.test.js +166 -0
- package/dist/test/membrane-terrain.test.js +170 -0
- package/dist/test/memory-log.test.js +202 -0
- package/dist/test/mentions-e2e.test.js +261 -0
- package/dist/test/mentions-ledger.test.js +265 -0
- package/dist/test/mentions-listen.test.js +265 -0
- package/dist/test/mentions.test.js +238 -0
- package/dist/test/message-selection.test.js +114 -0
- package/dist/test/mo-glyph.test.js +22 -0
- package/dist/test/model-resolution.test.js +166 -0
- package/dist/test/narrate-validate.test.js +77 -0
- package/dist/test/narrate-wait.test.js +160 -0
- package/dist/test/narrative-format.test.js +219 -0
- package/dist/test/narrative-menu.test.js +150 -0
- package/dist/test/narrative-sort.test.js +75 -0
- package/dist/test/no-pan.test.js +60 -0
- package/dist/test/pane-layout.test.js +153 -0
- package/dist/test/paths.test.js +37 -0
- package/dist/test/payload-inline.test.js +91 -0
- package/dist/test/precache-fonts.test.js +72 -0
- package/dist/test/presence-gate.test.js +440 -0
- package/dist/test/presence.test.js +160 -0
- package/dist/test/read-events.test.js +220 -0
- package/dist/test/remote-meld.test.js +128 -0
- package/dist/test/revive-cli.test.js +93 -0
- package/dist/test/search-floor.test.js +206 -0
- package/dist/test/search-fusion.test.js +168 -0
- package/dist/test/search.test.js +205 -0
- package/dist/test/security.test.js +48 -0
- package/dist/test/selection-wiring.test.js +61 -0
- package/dist/test/session-affinity.test.js +295 -0
- package/dist/test/signal-source.test.js +32 -0
- package/dist/test/socket-account.test.js +268 -0
- package/dist/test/spawn-toml.test.js +72 -0
- package/dist/test/speaker-color.test.js +90 -0
- package/dist/test/tailscale-rebind.test.js +79 -0
- package/dist/test/terminal-cursor.test.js +61 -0
- package/dist/test/terminal-resync.test.js +23 -0
- package/dist/test/terminal-size.test.js +36 -0
- package/dist/test/thin-cli.test.js +382 -0
- package/dist/test/token-bootstrap.test.js +272 -0
- package/dist/test/token-devices.test.js +646 -0
- package/dist/test/token-lapse.test.js +94 -0
- package/dist/test/token-ring.test.js +188 -0
- package/dist/test/token-rotate-cli.test.js +58 -0
- package/dist/test/token-routes.test.js +95 -0
- package/dist/test/tool-uses.test.js +1006 -0
- package/dist/test/usage-ledger.test.js +126 -0
- package/dist/test/view-url.test.js +627 -0
- package/dist/test/wiki-links.test.js +57 -0
- package/dist/test/wiki-sources.test.js +85 -0
- package/dist/test/wiki-window.test.js +31 -0
- package/dist/test/wiki.test.js +399 -0
- package/dist/test/wordpiece.test.js +68 -0
- package/dist/test/wrangler-route.test.js +27 -0
- package/dist/tls.js +148 -0
- package/dist/tmux.js +213 -0
- package/dist/user-agent.js +46 -0
- package/docker-entrypoint.sh +154 -0
- package/docs/README.md +3 -0
- package/docs/absorb.md +231 -0
- package/docs/cost-ledger.md +125 -0
- package/docs/daemon-api.md +189 -0
- package/docs/diagrams.md +163 -0
- package/docs/doctor.md +36 -0
- package/docs/export-scope.md +69 -0
- package/docs/files-view.md +133 -0
- package/docs/link-previews.md +38 -0
- package/docs/math.md +32 -0
- package/docs/mcp.md +69 -0
- package/docs/memory-search.md +75 -0
- package/docs/mentions.md +101 -0
- package/docs/multi-brane.md +14 -0
- package/docs/passkeys.md +165 -0
- package/docs/presence.md +202 -0
- package/docs/releasing.md +104 -0
- package/docs/rendering-warnings.md +22 -0
- package/docs/request-layer.md +72 -0
- package/docs/selection-store.md +50 -0
- package/docs/session-affinity.md +61 -0
- package/docs/terminal-mirroring.md +243 -0
- package/docs/token-rotation.md +431 -0
- package/docs/view-urls.md +134 -0
- package/docs/web-app.md +23 -0
- package/docs/wiki.md +43 -0
- package/expo/dist/_expo/static/js/web/entry-e94247f119fdcd3b424a1764233a41d2.js +1099 -0
- package/expo/dist/apple-touch-icon.png +0 -0
- package/expo/dist/favicon.svg +3 -0
- package/expo/dist/index.html +177 -0
- package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/expo/dist/katex/katex.min.css +1 -0
- package/expo/dist/manifest.json +16 -0
- package/expo/dist/metadata.json +1 -0
- package/expo/dist/og/membrane.v1.png +0 -0
- package/expo/dist/pdf.min.mjs +28 -0
- package/expo/dist/pdf.worker.min.mjs +28 -0
- package/expo/dist/sw.js +154 -0
- package/package.json +79 -27
- package/protocols/LIVE_loop.md +407 -0
- package/protocols/bebrief.md +41 -0
- package/protocols/digest.md +231 -0
- package/protocols/fly.md +266 -0
- package/protocols/idle.md +81 -0
- package/protocols/keeper.md +829 -0
- package/protocols/markdown.md +80 -0
- package/protocols/memory.md +419 -0
- package/protocols/mentions.md +93 -0
- package/protocols/payloads.md +53 -0
- package/protocols/prelude.md +91 -0
- package/protocols/ramify.md +105 -0
- package/protocols/review.md +356 -0
- package/protocols/rrr.md +232 -0
- package/protocols/scribe.md +429 -0
- package/protocols/summarize.md +203 -0
- package/protocols/twin.md +174 -0
- package/protocols/wiki.md +438 -0
- package/protocols/worker.md +38 -0
- package/wiki/shared/append-only-event-log.md +148 -0
- package/wiki/shared/index.md +53 -0
- package/wiki/shared/owning-your-narratives.md +161 -0
- package/wiki/shared/premature-ontologies.md +435 -0
- package/wiki/shared/sedimentary-superintelligence.md +128 -0
- package/wiki/shared/sequestering-the-narrative.md +143 -0
- package/wiki/shared/why-wikis-work.md +216 -0
- package/.travis.yml +0 -7
- package/lib/brane.cjs.js +0 -12
- package/lib/brane.cjs.js.map +0 -1
- package/lib/brane.d.ts +0 -11
- package/lib/brane.js +0 -6
- package/lib/brane.js.map +0 -1
- package/lib/tests.d.ts +0 -1
- package/lib/tests.js +0 -8
- package/lib/tests.js.map +0 -1
- package/rollup.config.js +0 -21
- package/scripts/test.sh +0 -8
- package/src/brane.ts +0 -21
- package/src/tests.ts +0 -8
- package/tsconfig.json +0 -16
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/** Find-in-file for the Files tab: the decisions, without the DOM.
|
|
2
|
+
*
|
|
3
|
+
* The web hook (`expo/lib/findInFile.ts`) flattens a file body's text nodes
|
|
4
|
+
* into one string, asks this module where the query occurs, and maps each
|
|
5
|
+
* hit back onto the nodes it spans as a `Range`. Everything that is a rule
|
|
6
|
+
* rather than a DOM call lives here, so `src/test/find-in-text.test.ts` can
|
|
7
|
+
* pin it without a browser — smart-case, the `:N` line jump, which hit is
|
|
8
|
+
* current after the query changes, and the offset → node mapping that has
|
|
9
|
+
* to be exactly right for a highlight to land on the characters it names. */
|
|
10
|
+
/** Hits past this are not collected: a one-letter query in a large file would
|
|
11
|
+
* otherwise build tens of thousands of Ranges for a counter nobody reads. */
|
|
12
|
+
export const MAX_FIND_MATCHES = 5000;
|
|
13
|
+
/** Smart-case, as in vim and ripgrep: an all-lowercase query matches any case,
|
|
14
|
+
* and a single uppercase letter makes the whole query exact. */
|
|
15
|
+
export function isCaseSensitive(query) {
|
|
16
|
+
return query !== query.toLowerCase();
|
|
17
|
+
}
|
|
18
|
+
/** `:120` → 120. Anything else — `:`, `:0`, `: 12`, `12` — is not a jump and
|
|
19
|
+
* is searched for literally. */
|
|
20
|
+
export function parseLineJump(query) {
|
|
21
|
+
const m = /^:(\d+)$/.exec(query.trim());
|
|
22
|
+
if (!m)
|
|
23
|
+
return null;
|
|
24
|
+
const n = Number(m[1]);
|
|
25
|
+
return n > 0 ? n : null;
|
|
26
|
+
}
|
|
27
|
+
/** Every non-overlapping occurrence of `query` in `text`, in order. The query
|
|
28
|
+
* is literal (no regex, no fuzziness) and is not trimmed: a trailing space is
|
|
29
|
+
* how you ask for the word and not the prefix. */
|
|
30
|
+
export function findMatches(text, query, limit = MAX_FIND_MATCHES) {
|
|
31
|
+
const matches = [];
|
|
32
|
+
if (!query)
|
|
33
|
+
return { matches, capped: false };
|
|
34
|
+
const sensitive = isCaseSensitive(query);
|
|
35
|
+
const hay = sensitive ? text : text.toLowerCase();
|
|
36
|
+
const needle = sensitive ? query : query.toLowerCase();
|
|
37
|
+
// toLowerCase can change a string's length (İ → i̇), which would shift every
|
|
38
|
+
// offset after it. Fall back to the exact-case search rather than highlight
|
|
39
|
+
// the wrong characters.
|
|
40
|
+
const [h, n] = hay.length === text.length ? [hay, needle] : [text, query];
|
|
41
|
+
let from = 0;
|
|
42
|
+
while (from <= h.length - n.length) {
|
|
43
|
+
const at = h.indexOf(n, from);
|
|
44
|
+
if (at < 0)
|
|
45
|
+
break;
|
|
46
|
+
if (matches.length >= limit)
|
|
47
|
+
return { matches, capped: true };
|
|
48
|
+
matches.push({ start: at, end: at + n.length });
|
|
49
|
+
from = at + n.length;
|
|
50
|
+
}
|
|
51
|
+
return { matches, capped: false };
|
|
52
|
+
}
|
|
53
|
+
/** The index of the first hit starting at or after `offset`, wrapping to 0 when
|
|
54
|
+
* every hit is before it; -1 when there are none. This is how the current hit
|
|
55
|
+
* stays put while the query is typed: each keystroke re-anchors on where the
|
|
56
|
+
* last current hit started, so `use` → `useM` → `useMemo` walks forward
|
|
57
|
+
* through the file instead of snapping back to its top. */
|
|
58
|
+
export function firstAtOrAfter(matches, offset) {
|
|
59
|
+
if (matches.length === 0)
|
|
60
|
+
return -1;
|
|
61
|
+
let lo = 0;
|
|
62
|
+
let hi = matches.length;
|
|
63
|
+
while (lo < hi) {
|
|
64
|
+
const mid = (lo + hi) >> 1;
|
|
65
|
+
if (matches[mid].start < offset)
|
|
66
|
+
lo = mid + 1;
|
|
67
|
+
else
|
|
68
|
+
hi = mid;
|
|
69
|
+
}
|
|
70
|
+
return lo < matches.length ? lo : 0;
|
|
71
|
+
}
|
|
72
|
+
/** Next (`+1`) or previous (`-1`) hit, wrapping at both ends. */
|
|
73
|
+
export function stepMatch(current, count, dir) {
|
|
74
|
+
if (count <= 0)
|
|
75
|
+
return -1;
|
|
76
|
+
if (current < 0)
|
|
77
|
+
return dir > 0 ? 0 : count - 1;
|
|
78
|
+
return (current + dir + count) % count;
|
|
79
|
+
}
|
|
80
|
+
/** Map a span of the flattened text onto the nodes it covers, or null when an
|
|
81
|
+
* end falls in a separator (which `findMatches` can only produce if the query
|
|
82
|
+
* itself contains the separator character). `slots` must be in ascending,
|
|
83
|
+
* non-overlapping order, which is how a tree walk produces them. */
|
|
84
|
+
export function locateSpan(slots, span) {
|
|
85
|
+
const start = locatePoint(slots, span.start, false);
|
|
86
|
+
const end = locatePoint(slots, span.end, true);
|
|
87
|
+
return start && end ? { start, end } : null;
|
|
88
|
+
}
|
|
89
|
+
/** The slot containing `pos`. A position on the boundary between two slots
|
|
90
|
+
* belongs to the later one as a start and to the earlier one as an end, so a
|
|
91
|
+
* Range never begins at the very end of a node or finishes at the very start
|
|
92
|
+
* of the next — both would be empty-looking edges on the highlight. */
|
|
93
|
+
function locatePoint(slots, pos, isEnd) {
|
|
94
|
+
let lo = 0;
|
|
95
|
+
let hi = slots.length - 1;
|
|
96
|
+
while (lo <= hi) {
|
|
97
|
+
const mid = (lo + hi) >> 1;
|
|
98
|
+
const s = slots[mid];
|
|
99
|
+
const lower = s.start;
|
|
100
|
+
const upper = s.start + s.length;
|
|
101
|
+
if (isEnd ? pos <= lower : pos < lower)
|
|
102
|
+
hi = mid - 1;
|
|
103
|
+
else if (isEnd ? pos > upper : pos >= upper)
|
|
104
|
+
lo = mid + 1;
|
|
105
|
+
else
|
|
106
|
+
return { node: mid, offset: pos - s.start };
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
//# sourceMappingURL=find-in-text.js.map
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which hue a narrative's awaiting glow wears on this device.
|
|
3
|
+
*
|
|
4
|
+
* The glow used to be the narrative's speaker hash (`speakerHue`), which knows
|
|
5
|
+
* nothing about what else is on screen: two agents parked side by side could
|
|
6
|
+
* glow the same purple, and 🎨 Recolor's `salt + 1` was a fresh random draw
|
|
7
|
+
* that could land on another. The glow's job is to let the operator tell
|
|
8
|
+
* sessions apart at a glance, so the hue is *allocated* instead:
|
|
9
|
+
*
|
|
10
|
+
* - **Chosen once, then sticky.** The first time a narrative glows here it
|
|
11
|
+
* takes the middle of the widest empty arc among the hues of the other
|
|
12
|
+
* narratives that glowed recently, and keeps that hue on every later listen.
|
|
13
|
+
* Nothing moves it automatically — an operator learns sessions by color, and
|
|
14
|
+
* a hue that shifted whenever a neighbour lit up would be unlearnable.
|
|
15
|
+
* - **Recolor rolls within the clear arcs.** A random hue at least half as far
|
|
16
|
+
* from the others as the best spot on the wheel, a visible step from the
|
|
17
|
+
* current one, and clear of the hues this narrative was just recolored away
|
|
18
|
+
* from. Always taking the widest gap's middle made it a toggle: with one
|
|
19
|
+
* other session glowing there is one such middle, and alone on the wheel
|
|
20
|
+
* the fallback flipped to the opposite hue and back again.
|
|
21
|
+
*
|
|
22
|
+
* "Recently" rather than "glowing right now": a session between replies will
|
|
23
|
+
* glow again in a minute, so its hue is still spoken for. Rather than ask
|
|
24
|
+
* every screen for a list of live narratives (the header has none), the store
|
|
25
|
+
* keeps when each hue last glowed and treats the {@link RECENT_LIMIT} most
|
|
26
|
+
* recent within {@link RECENT_MS} as taken. A dead session drops out on its
|
|
27
|
+
* own by never glowing again.
|
|
28
|
+
*
|
|
29
|
+
* Client-side only, like the Recolor salt: a preference of this device's
|
|
30
|
+
* eyes, never sent to the daemon. Window-free so `src/test/glow-hues.test.ts`
|
|
31
|
+
* can pin it; `expo/lib/glow-hues.ts` binds it to `localStorage`.
|
|
32
|
+
*/
|
|
33
|
+
import { speakerHue } from "./speaker-color.js";
|
|
34
|
+
export const GLOW_HUES_STORAGE_KEY = "brane:glow-hues";
|
|
35
|
+
/** How long a hue stays spoken for after it last glowed. */
|
|
36
|
+
export const RECENT_MS = 7 * 24 * 60 * 60 * 1000;
|
|
37
|
+
/** At most this many hues count as taken — the most recently glowing. Past a
|
|
38
|
+
* dozen, every gap is under 30° anyway, and the sessions that matter are the
|
|
39
|
+
* ones the operator saw last. */
|
|
40
|
+
export const RECENT_LIMIT = 12;
|
|
41
|
+
/** `seen` is refreshed on render, so writes are coalesced to one per minute. */
|
|
42
|
+
const SEEN_RESOLUTION_MS = 60 * 1000;
|
|
43
|
+
/** A Recolor must change the color, not shade it: nothing nearer the current
|
|
44
|
+
* hue than this. At 45° a fifth of presses moved under 70°, pink to rose or
|
|
45
|
+
* sky to blue, and read as no change at all; 100° crosses a color family. */
|
|
46
|
+
export const MIN_RECOLOR_STEP = 100;
|
|
47
|
+
/** A Recolor keeps at least this fraction of the best clearance from the
|
|
48
|
+
* others that any hue on the wheel could have… */
|
|
49
|
+
export const RECOLOR_CLEARANCE = 0.5;
|
|
50
|
+
/** …or, when that leaves nowhere a full step away, this fraction. The step
|
|
51
|
+
* outranks the clearance: with one other session opposite, the arc at half
|
|
52
|
+
* the best clearance is 180° wide and a 100° step can't be taken inside it. */
|
|
53
|
+
export const RECOLOR_CLEARANCE_MIN = 0.25;
|
|
54
|
+
/** Hues this narrative was recolored away from are rejected for this long… */
|
|
55
|
+
export const REJECTED_MS = 60 * 60 * 1000;
|
|
56
|
+
/** …at most this many of them… */
|
|
57
|
+
export const REJECTED_LIMIT = 4;
|
|
58
|
+
/** …and a Recolor keeps this far from each, so a second press can't undo the
|
|
59
|
+
* first by stepping back to where it started. */
|
|
60
|
+
export const REJECTED_CLEARANCE = 60;
|
|
61
|
+
/** Shortest distance between two hues around the wheel, in degrees. */
|
|
62
|
+
export function hueDistance(a, b) {
|
|
63
|
+
const d = Math.abs(a - b) % 360;
|
|
64
|
+
return Math.min(d, 360 - d);
|
|
65
|
+
}
|
|
66
|
+
const norm = (h) => ((Math.round(h) % 360) + 360) % 360;
|
|
67
|
+
/**
|
|
68
|
+
* Midpoints of the empty arcs between `taken` hues, widest first. Ties keep
|
|
69
|
+
* wheel order, so the answer is deterministic. Empty when nothing is taken —
|
|
70
|
+
* the caller decides what a free wheel means.
|
|
71
|
+
*/
|
|
72
|
+
export function gapMidpoints(taken) {
|
|
73
|
+
const hues = [...new Set(taken.map(norm))].sort((a, b) => a - b);
|
|
74
|
+
if (hues.length === 0)
|
|
75
|
+
return [];
|
|
76
|
+
const gaps = hues.map((h, i) => {
|
|
77
|
+
const next = i + 1 < hues.length ? hues[i + 1] : hues[0] + 360;
|
|
78
|
+
const width = next - h;
|
|
79
|
+
return { width, mid: norm(h + width / 2) };
|
|
80
|
+
});
|
|
81
|
+
return gaps
|
|
82
|
+
.map((g, i) => ({ ...g, i }))
|
|
83
|
+
.sort((a, b) => b.width - a.width || a.i - b.i)
|
|
84
|
+
.map((g) => g.mid);
|
|
85
|
+
}
|
|
86
|
+
export function parseGlowAssignments(raw) {
|
|
87
|
+
if (!raw)
|
|
88
|
+
return {};
|
|
89
|
+
try {
|
|
90
|
+
const parsed = JSON.parse(raw);
|
|
91
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
92
|
+
return {};
|
|
93
|
+
const out = {};
|
|
94
|
+
for (const [nid, v] of Object.entries(parsed)) {
|
|
95
|
+
const a = v;
|
|
96
|
+
if (a && Number.isFinite(a.hue) && Number.isFinite(a.seen)) {
|
|
97
|
+
out[nid] = { hue: norm(a.hue), seen: a.seen };
|
|
98
|
+
const rejected = Array.isArray(a.rejected)
|
|
99
|
+
? a.rejected.filter((r) => r && Number.isFinite(r.hue) && Number.isFinite(r.at))
|
|
100
|
+
: [];
|
|
101
|
+
if (rejected.length)
|
|
102
|
+
out[nid].rejected = rejected.map((r) => ({ hue: norm(r.hue), at: r.at }));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return {};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** Hues spoken for by narratives other than `self`: the most recently glowing
|
|
112
|
+
* {@link RECENT_LIMIT}, within {@link RECENT_MS}. */
|
|
113
|
+
export function takenHues(assignments, self, now) {
|
|
114
|
+
return Object.entries(assignments)
|
|
115
|
+
.filter(([nid, a]) => nid !== self && now - a.seen <= RECENT_MS)
|
|
116
|
+
.sort((a, b) => b[1].seen - a[1].seen)
|
|
117
|
+
.slice(0, RECENT_LIMIT)
|
|
118
|
+
.map(([, a]) => a.hue);
|
|
119
|
+
}
|
|
120
|
+
/** The hue a narrative gets the first time it glows. On a free wheel that is
|
|
121
|
+
* its speaker hash — the color it wore before this allocator existed. */
|
|
122
|
+
export function firstHue(nid, taken) {
|
|
123
|
+
return gapMidpoints(taken)[0] ?? speakerHue(nid);
|
|
124
|
+
}
|
|
125
|
+
/** Nearest distance from `hue` to any of `hues`; a half turn when there are none. */
|
|
126
|
+
function clearance(hue, hues) {
|
|
127
|
+
return hues.reduce((m, h) => Math.min(m, hueDistance(hue, h)), 180);
|
|
128
|
+
}
|
|
129
|
+
/** Where 🎨 Recolor moves a hue: a random whole degree at least
|
|
130
|
+
* {@link MIN_RECOLOR_STEP} from the current hue, clear of the `rejected` ones,
|
|
131
|
+
* and at least {@link RECOLOR_CLEARANCE} of the best available clearance from
|
|
132
|
+
* the others. On a wheel too crowded for all three, the clearance drops to
|
|
133
|
+
* {@link RECOLOR_CLEARANCE_MIN} first, then the rejected hues give way oldest
|
|
134
|
+
* first, and only then the step; with nothing left, the widest gap's middle, with the current
|
|
135
|
+
* hue counted as taken so it still moves. */
|
|
136
|
+
export function recolorHue(current, taken, opts = {}) {
|
|
137
|
+
const { rejected = [], random = Math.random } = opts;
|
|
138
|
+
const others = [...new Set(taken.map(norm))];
|
|
139
|
+
const left = rejected.map(norm);
|
|
140
|
+
const best = others.length ? gapMidpoints(others).reduce((m, mid) => Math.max(m, clearance(mid, others)), 0) : 180;
|
|
141
|
+
const clearAt = (fraction) => {
|
|
142
|
+
const out = [];
|
|
143
|
+
for (let h = 0; h < 360; h++)
|
|
144
|
+
if (clearance(h, others) >= best * fraction)
|
|
145
|
+
out.push(h);
|
|
146
|
+
return out;
|
|
147
|
+
};
|
|
148
|
+
const clear = clearAt(RECOLOR_CLEARANCE);
|
|
149
|
+
const looser = clearAt(RECOLOR_CLEARANCE_MIN);
|
|
150
|
+
const stepped = (hues) => hues.filter((h) => hueDistance(h, current) >= MIN_RECOLOR_STEP);
|
|
151
|
+
// `rejected` is newest first, so the hues it lets go of are the oldest: the
|
|
152
|
+
// one this press is about to leave, and the one before, are kept longest.
|
|
153
|
+
const fresh = (hues, n) => hues.filter((h) => clearance(h, left.slice(0, n)) >= REJECTED_CLEARANCE);
|
|
154
|
+
const tiers = [];
|
|
155
|
+
for (let n = left.length; n > 0; n--)
|
|
156
|
+
tiers.push(fresh(stepped(clear), n), fresh(stepped(looser), n));
|
|
157
|
+
tiers.push(stepped(clear), stepped(looser), clear.filter((h) => h !== norm(current)));
|
|
158
|
+
const pool = tiers.find((t) => t.length > 0);
|
|
159
|
+
if (!pool)
|
|
160
|
+
return gapMidpoints([...others, current])[0];
|
|
161
|
+
return pool[Math.min(pool.length - 1, Math.floor(random() * pool.length))];
|
|
162
|
+
}
|
|
163
|
+
/** The allocator over a key-value store — `localStorage` in the app. Every
|
|
164
|
+
* store access is guarded: an unreadable store is an empty one, and a failed
|
|
165
|
+
* write still returns the hue it computed. */
|
|
166
|
+
export class GlowHues {
|
|
167
|
+
store;
|
|
168
|
+
now;
|
|
169
|
+
random;
|
|
170
|
+
constructor(store, now = Date.now, random = Math.random) {
|
|
171
|
+
this.store = store;
|
|
172
|
+
this.now = now;
|
|
173
|
+
this.random = random;
|
|
174
|
+
}
|
|
175
|
+
read() {
|
|
176
|
+
try {
|
|
177
|
+
return parseGlowAssignments(this.store.getItem(GLOW_HUES_STORAGE_KEY));
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return {};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
write(assignments) {
|
|
184
|
+
try {
|
|
185
|
+
this.store.setItem(GLOW_HUES_STORAGE_KEY, JSON.stringify(assignments));
|
|
186
|
+
}
|
|
187
|
+
catch { }
|
|
188
|
+
}
|
|
189
|
+
/** The hue `nid` glows in, assigning one the first time. Call it only for a
|
|
190
|
+
* narrative that is glowing: that call is what marks the hue as in use. */
|
|
191
|
+
hueFor(nid) {
|
|
192
|
+
const assignments = this.read();
|
|
193
|
+
const now = this.now();
|
|
194
|
+
const existing = assignments[nid];
|
|
195
|
+
if (existing) {
|
|
196
|
+
if (now - existing.seen >= SEEN_RESOLUTION_MS) {
|
|
197
|
+
assignments[nid] = { ...existing, seen: now };
|
|
198
|
+
this.write(assignments);
|
|
199
|
+
}
|
|
200
|
+
return existing.hue;
|
|
201
|
+
}
|
|
202
|
+
const hue = firstHue(nid, takenHues(assignments, nid, now));
|
|
203
|
+
assignments[nid] = { hue, seen: now };
|
|
204
|
+
this.write(assignments);
|
|
205
|
+
return hue;
|
|
206
|
+
}
|
|
207
|
+
/** 🎨 Recolor: move `nid` somewhere new and clear of the others, and
|
|
208
|
+
* remember the hue it left so the next few presses don't go back to it. */
|
|
209
|
+
recolor(nid) {
|
|
210
|
+
const assignments = this.read();
|
|
211
|
+
const now = this.now();
|
|
212
|
+
const current = assignments[nid]?.hue ?? speakerHue(nid);
|
|
213
|
+
const rejected = [{ hue: current, at: now }, ...(assignments[nid]?.rejected ?? [])]
|
|
214
|
+
.filter((r) => now - r.at <= REJECTED_MS)
|
|
215
|
+
.slice(0, REJECTED_LIMIT);
|
|
216
|
+
const hue = recolorHue(current, takenHues(assignments, nid, now), {
|
|
217
|
+
rejected: rejected.map((r) => r.hue),
|
|
218
|
+
random: this.random,
|
|
219
|
+
});
|
|
220
|
+
assignments[nid] = { hue, seen: now, rejected };
|
|
221
|
+
this.write(assignments);
|
|
222
|
+
return hue;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
//# sourceMappingURL=glow-hues.js.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The hex view's two rules, window-free so the daemon (which decides a file
|
|
2
|
+
// is binary) and the app (which lays the bytes out) agree without a browser.
|
|
3
|
+
/** Bytes a binary file is served in, per request. 16 KB is 1,024 rows: enough
|
|
4
|
+
* to read a header by, and few enough DOM lines for a phone. */
|
|
5
|
+
export const HEX_PAGE_BYTES = 16 * 1024;
|
|
6
|
+
/** How far into a file the binary check looks — git's figure. */
|
|
7
|
+
export const BINARY_SNIFF_BYTES = 8000;
|
|
8
|
+
export const HEX_ROW_BYTES = 16;
|
|
9
|
+
/** Columns a row takes at `rowBytes` bytes: offset, gap, bytes, group gap,
|
|
10
|
+
* space, gutter. What the view measures against its width. */
|
|
11
|
+
export function hexRowColumns(rowBytes) {
|
|
12
|
+
return 8 + 2 + rowBytes * 3 + (rowBytes > 8 ? 1 : 0) + 1 + rowBytes;
|
|
13
|
+
}
|
|
14
|
+
/** A file is binary if a NUL appears in its first 8000 bytes: git's rule, and
|
|
15
|
+
* the one every diff tool and pager the operator uses already agrees with.
|
|
16
|
+
* UTF-16 text reads as binary under it, which is what git shows too. */
|
|
17
|
+
export function looksBinary(head) {
|
|
18
|
+
const n = Math.min(head.length, BINARY_SNIFF_BYTES);
|
|
19
|
+
for (let i = 0; i < n; i++)
|
|
20
|
+
if (head[i] === 0)
|
|
21
|
+
return true;
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
export function hexRows(bytes, offset = 0, rowBytes = HEX_ROW_BYTES) {
|
|
25
|
+
const rows = [];
|
|
26
|
+
for (let at = 0; at < bytes.length; at += rowBytes) {
|
|
27
|
+
let hex = "";
|
|
28
|
+
let ascii = "";
|
|
29
|
+
for (let j = 0; j < rowBytes; j++) {
|
|
30
|
+
if (j === 8)
|
|
31
|
+
hex += " ";
|
|
32
|
+
const i = at + j;
|
|
33
|
+
if (i < bytes.length) {
|
|
34
|
+
const b = bytes[i];
|
|
35
|
+
hex += (b < 16 ? "0" : "") + b.toString(16) + " ";
|
|
36
|
+
ascii += b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : ".";
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
hex += " ";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
rows.push({ offset: hexOffset(offset + at), hex, ascii });
|
|
43
|
+
}
|
|
44
|
+
return rows;
|
|
45
|
+
}
|
|
46
|
+
/** Where byte `j` of a row starts in its `hex` column. */
|
|
47
|
+
export function hexColumnOf(j, rowBytes = HEX_ROW_BYTES) {
|
|
48
|
+
return j * 3 + (rowBytes > 8 && j >= 8 ? 1 : 0);
|
|
49
|
+
}
|
|
50
|
+
/** `xxd`'s layout: an 8-digit offset, the bytes (sixteen to a row in two
|
|
51
|
+
* groups of eight, or eight to a row on a phone), and the printable-ASCII
|
|
52
|
+
* gutter. `offset` is where `bytes` starts in the file, so a later page
|
|
53
|
+
* numbers its rows where they are. */
|
|
54
|
+
export function hexDumpRows(bytes, offset = 0, rowBytes = HEX_ROW_BYTES) {
|
|
55
|
+
return hexRows(bytes, offset, rowBytes).map((r) => `${r.offset} ${r.hex} ${r.ascii}`);
|
|
56
|
+
}
|
|
57
|
+
/** Eight hex digits, more past 4 GiB — the column widens rather than wraps. */
|
|
58
|
+
export function hexOffset(n) {
|
|
59
|
+
const s = n.toString(16);
|
|
60
|
+
return s.length >= 8 ? s : "0".repeat(8 - s.length) + s;
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=hex-dump.js.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The hex view's page arithmetic, window-free so it can be tested without a
|
|
2
|
+
// browser. A file is shown as whichever of its 16 KB pages have been loaded,
|
|
3
|
+
// in place, with a gap row standing for each unloaded run between them; a
|
|
4
|
+
// search hit names bytes, and these say which pages and rows those bytes are
|
|
5
|
+
// on.
|
|
6
|
+
import { HEX_PAGE_BYTES } from "./hex-dump.js";
|
|
7
|
+
/** The page that byte `offset` is on. Pages sit on multiples of the page
|
|
8
|
+
* size, so two loaded neighbours always join without a seam. */
|
|
9
|
+
export function pageOf(offset, pageBytes = HEX_PAGE_BYTES) {
|
|
10
|
+
return offset - (offset % pageBytes);
|
|
11
|
+
}
|
|
12
|
+
/** Every page a hit of `length` bytes at `at` touches. */
|
|
13
|
+
export function pagesOfHit(at, length, pageBytes = HEX_PAGE_BYTES) {
|
|
14
|
+
const out = [];
|
|
15
|
+
for (let p = pageOf(at, pageBytes); p < at + Math.max(1, length); p += pageBytes)
|
|
16
|
+
out.push(p);
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
/** The file as it will be drawn: loaded pages in order, a gap wherever the
|
|
20
|
+
* next page doesn't start where the last one ended, and a trailing gap
|
|
21
|
+
* until the page holding the end has been read. `size` is the file's size
|
|
22
|
+
* when known. */
|
|
23
|
+
export function hexSegments(pages, size) {
|
|
24
|
+
const sorted = [...pages].sort((a, b) => a.offset - b.offset);
|
|
25
|
+
const out = [];
|
|
26
|
+
let at = 0;
|
|
27
|
+
let end = size;
|
|
28
|
+
for (const p of sorted) {
|
|
29
|
+
if (p.offset > at)
|
|
30
|
+
out.push({ kind: "gap", from: at, to: p.offset });
|
|
31
|
+
out.push({ kind: "page", offset: p.offset });
|
|
32
|
+
at = p.offset + p.length;
|
|
33
|
+
if (p.eof)
|
|
34
|
+
end = at;
|
|
35
|
+
}
|
|
36
|
+
if (end === null || at < end)
|
|
37
|
+
out.push({ kind: "gap", from: at, to: end });
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
/** A hit's bytes, row by row: for each row it touches, the row's start and
|
|
41
|
+
* the first and last byte of the hit within it (inclusive, 0-based in the
|
|
42
|
+
* row). A hit that wraps a row is two spans, which is how it is painted in
|
|
43
|
+
* both columns. */
|
|
44
|
+
export function rowSpans(at, length, rowBytes) {
|
|
45
|
+
const out = [];
|
|
46
|
+
const end = at + Math.max(1, length);
|
|
47
|
+
for (let row = at - (at % rowBytes); row < end; row += rowBytes) {
|
|
48
|
+
out.push({ row, first: Math.max(at, row) - row, last: Math.min(end, row + rowBytes) - 1 - row });
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=hex-pages.js.map
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** The physics of the find-in-file hit drum, window-free.
|
|
2
|
+
*
|
|
3
|
+
* The drum's position is a real number in *hit units*: 2.0 means hit #3 is
|
|
4
|
+
* centered, 2.5 is halfway to #4. It does not wrap — reaching the first or
|
|
5
|
+
* last hit is information the reader needs — so a position past either end
|
|
6
|
+
* only ever exists as rubber-band overscroll on its way back. The page
|
|
7
|
+
* follows it in whole hits (`clampIndex`), snapping each onto the middle
|
|
8
|
+
* line as the drum passes it. */
|
|
9
|
+
/** The hit a position rests on: nearest, and never past either end. */
|
|
10
|
+
export function clampIndex(pos, count) {
|
|
11
|
+
if (count <= 0)
|
|
12
|
+
return -1;
|
|
13
|
+
return Math.max(0, Math.min(count - 1, Math.round(pos)));
|
|
14
|
+
}
|
|
15
|
+
/** How far past an end the drum can be pulled, in hits. */
|
|
16
|
+
export const RUBBER_BAND_LIMIT = 0.5;
|
|
17
|
+
/** Map a raw position (finger-tracked, unbounded) onto the drum: exact
|
|
18
|
+
* inside the range, and past either end an overscroll that grows ever more
|
|
19
|
+
* slowly toward `RUBBER_BAND_LIMIT` — the resistance that says "end". */
|
|
20
|
+
export function rubberBand(raw, count) {
|
|
21
|
+
const hi = Math.max(0, count - 1);
|
|
22
|
+
const band = (over) => (RUBBER_BAND_LIMIT * over) / (RUBBER_BAND_LIMIT + over);
|
|
23
|
+
if (raw < 0)
|
|
24
|
+
return -band(-raw);
|
|
25
|
+
if (raw > hi)
|
|
26
|
+
return hi + band(raw - hi);
|
|
27
|
+
return raw;
|
|
28
|
+
}
|
|
29
|
+
/** The nudge a step into an end gives the drum (hits/s): under the spring
|
|
30
|
+
* it travels about a quarter of a detent toward the wall and settles back,
|
|
31
|
+
* so Enter on the last hit visibly lands rather than doing nothing. */
|
|
32
|
+
export const BUMP_VELOCITY = 22;
|
|
33
|
+
/** Critically damped spring: fastest approach with no overshoot, so the page
|
|
34
|
+
* it drives never scrolls past a hit and comes back. */
|
|
35
|
+
export const SPRING_OMEGA = 16;
|
|
36
|
+
/** One integration step (semi-implicit Euler) toward `target`; `dt` in
|
|
37
|
+
* seconds, velocity in hits per second. */
|
|
38
|
+
export function springStep(x, v, target, dt, omega = SPRING_OMEGA) {
|
|
39
|
+
const a = -omega * omega * (x - target) - 2 * omega * v;
|
|
40
|
+
const nv = v + a * dt;
|
|
41
|
+
return { x: x + nv * dt, v: nv };
|
|
42
|
+
}
|
|
43
|
+
/** Close enough to call it landed. */
|
|
44
|
+
export function springAtRest(x, v, target) {
|
|
45
|
+
return Math.abs(x - target) < 0.002 && Math.abs(v) < 0.05;
|
|
46
|
+
}
|
|
47
|
+
/** How far a flick carries, in seconds of release velocity — the same
|
|
48
|
+
* projection iOS scroll views use (deceleration 0.998/ms ≈ 0.5 s), trimmed
|
|
49
|
+
* so a casual flick skims a handful of hits rather than the whole file. */
|
|
50
|
+
export const THROW_SECONDS = 0.35;
|
|
51
|
+
/** Where a release at `pos` moving `v` hits/s comes to rest: a whole hit,
|
|
52
|
+
* and never past either end — a flick into the last hit stops there, with
|
|
53
|
+
* its leftover velocity spent as a bounce against the wall. */
|
|
54
|
+
export function throwTarget(pos, v, count) {
|
|
55
|
+
return clampIndex(pos + v * THROW_SECONDS, count);
|
|
56
|
+
}
|
|
57
|
+
/** Release velocity from recent pointer samples, in px/ms: the slope over the
|
|
58
|
+
* last `windowMs`, so a finger that stopped before lifting throws nothing. */
|
|
59
|
+
export function releaseVelocity(samples, now, windowMs = 80) {
|
|
60
|
+
const recent = samples.filter((s) => now - s.t <= windowMs);
|
|
61
|
+
if (recent.length < 2)
|
|
62
|
+
return 0;
|
|
63
|
+
const first = recent[0];
|
|
64
|
+
const last = recent[recent.length - 1];
|
|
65
|
+
const dt = last.t - first.t;
|
|
66
|
+
return dt > 0 ? (last.y - first.y) / dt : 0;
|
|
67
|
+
}
|
|
68
|
+
/** A hit total short enough for the drum's face: exact below 1,000, then
|
|
69
|
+
* `1.2k`. Rounded down, so the total never claims a hit that isn't there;
|
|
70
|
+
* the ordinal beside it stays exact, which is what tells hit 1,234 from
|
|
71
|
+
* 1,235. */
|
|
72
|
+
export function compactCount(n) {
|
|
73
|
+
if (n < 1000)
|
|
74
|
+
return String(n);
|
|
75
|
+
if (n < 10000)
|
|
76
|
+
return `${Math.floor(n / 100) / 10}k`;
|
|
77
|
+
return `${Math.floor(n / 1000)}k`;
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=hit-drum.js.map
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Which narrative ids a screen is allowed to linkify.
|
|
2
|
+
*
|
|
3
|
+
* `narrativeIdLinks` links only ids that actually exist, so that arbitrary
|
|
4
|
+
* strings of the same shape don't become dead links. That refusal is right,
|
|
5
|
+
* but it makes the *set* load-bearing in a way that is easy to miss: an
|
|
6
|
+
* incomplete set is indistinguishable, on screen, from a narrative that isn't
|
|
7
|
+
* there. Nothing renders differently. Nothing is logged. A reference just
|
|
8
|
+
* quietly stops being a link.
|
|
9
|
+
*
|
|
10
|
+
* So the rule this module exists to enforce is: **linkification degrades, it
|
|
11
|
+
* does not switch off.** Ids come from every source the screen already has,
|
|
12
|
+
* ordered by how little can go wrong with them:
|
|
13
|
+
*
|
|
14
|
+
* viewing — the route param. No network, always available.
|
|
15
|
+
* pushed — the control channel's `state` frame, sent on connect over the
|
|
16
|
+
* socket the screen needs anyway, and covering every narrative
|
|
17
|
+
* including archived ones.
|
|
18
|
+
* listed — the `GET /api/narratives` sweep. The heaviest request the
|
|
19
|
+
* screen makes, and the one that fails first on a slow link.
|
|
20
|
+
*
|
|
21
|
+
* Before this, `listed` was the only source. On a slow or flaky connection it
|
|
22
|
+
* would fail, its rejection was swallowed, the effect could not re-fire, and
|
|
23
|
+
* every narrative reference on the screen stayed dead until a reload — while
|
|
24
|
+
* the conversation itself, fetched separately, rendered perfectly. That is
|
|
25
|
+
* what makes it read as "the linkifier is broken" rather than "a request
|
|
26
|
+
* didn't land".
|
|
27
|
+
*
|
|
28
|
+
* Window-free on purpose, like `view-url.ts`, so the property can be tested
|
|
29
|
+
* without a browser.
|
|
30
|
+
*/
|
|
31
|
+
function addAll(into, from) {
|
|
32
|
+
if (!from)
|
|
33
|
+
return;
|
|
34
|
+
for (const n of from) {
|
|
35
|
+
if (n && typeof n.id === "string" && n.id)
|
|
36
|
+
into.add(n.id);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Every narrative id this screen can vouch for.
|
|
40
|
+
*
|
|
41
|
+
* Deliberately a union rather than a fallback chain: a source being present
|
|
42
|
+
* says nothing about it being complete. The `state` frame is a snapshot from
|
|
43
|
+
* connect time and misses anything spawned since; the list sweep is a moment
|
|
44
|
+
* later. Taking both costs nothing and each covers the other's gap.
|
|
45
|
+
*
|
|
46
|
+
* Note what is *not* a source: ids parsed out of event `re` arrays. Those are
|
|
47
|
+
* never validated on write, so trusting them would hand the dead-link problem
|
|
48
|
+
* back to whoever wrote the reference — which is exactly what the existence
|
|
49
|
+
* check is there to prevent. */
|
|
50
|
+
export function collectKnownNids(sources) {
|
|
51
|
+
const known = new Set();
|
|
52
|
+
if (sources.viewing)
|
|
53
|
+
known.add(sources.viewing);
|
|
54
|
+
addAll(known, sources.pushed);
|
|
55
|
+
addAll(known, sources.listed);
|
|
56
|
+
return known;
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=known-nids.js.map
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** The browser's scroll keys, for a list that doesn't hold focus.
|
|
2
|
+
*
|
|
3
|
+
* A browser gives PageUp/PageDown, Home/End and the vertical arrows to the
|
|
4
|
+
* scroller that holds focus, or to the document when nothing does. The
|
|
5
|
+
* narrative list is an inner ScrollView, and the document has nothing to
|
|
6
|
+
* scroll on purpose (see public/index.html). So on a freshly loaded page, or
|
|
7
|
+
* after a click in the header, those keys reach no scroller and do nothing.
|
|
8
|
+
* The list screen hears them on `document` and moves its own scroller. This
|
|
9
|
+
* file works out where to scroll to. Deciding whether the screen should handle
|
|
10
|
+
* the key at all needs the DOM, so that part stays in the screen.
|
|
11
|
+
*
|
|
12
|
+
* The steps match Chromium's: 40 px per arrow press, and 87.5% of the visible
|
|
13
|
+
* height per page, so a line from the old page stays in view.
|
|
14
|
+
*/
|
|
15
|
+
export const ARROW_STEP_PX = 40;
|
|
16
|
+
export const PAGE_FRACTION = 0.875;
|
|
17
|
+
const SCROLL_KEYS = new Set(["PageUp", "PageDown", "Home", "End", "ArrowUp", "ArrowDown"]);
|
|
18
|
+
/** Whether this keydown is one the list should scroll for. Modified keys are
|
|
19
|
+
* left alone: ⌘↑/⌘↓ are the browser's own top/bottom keys, ⇧↑/⇧↓ extend a
|
|
20
|
+
* selection, and ⌥ and ⌃ belong to the OS. */
|
|
21
|
+
export function isListScrollKey(e) {
|
|
22
|
+
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey)
|
|
23
|
+
return false;
|
|
24
|
+
return SCROLL_KEYS.has(e.key);
|
|
25
|
+
}
|
|
26
|
+
/** The scrollTop that `key` should move a scroller to, or `null` if the key
|
|
27
|
+
* doesn't scroll.
|
|
28
|
+
*
|
|
29
|
+
* `from` is usually the scroller's current `scrollTop`. While a smooth scroll
|
|
30
|
+
* is still animating, pass the target it is heading for instead. Otherwise a
|
|
31
|
+
* second PageDown pressed mid-animation starts from wherever the animation
|
|
32
|
+
* had got to, and falls short of two pages. */
|
|
33
|
+
export function listScrollTarget(key, from, m) {
|
|
34
|
+
const max = Math.max(0, m.scrollHeight - m.clientHeight);
|
|
35
|
+
const page = Math.max(1, m.clientHeight * PAGE_FRACTION);
|
|
36
|
+
let to;
|
|
37
|
+
switch (key) {
|
|
38
|
+
case "ArrowUp":
|
|
39
|
+
to = from - ARROW_STEP_PX;
|
|
40
|
+
break;
|
|
41
|
+
case "ArrowDown":
|
|
42
|
+
to = from + ARROW_STEP_PX;
|
|
43
|
+
break;
|
|
44
|
+
case "PageUp":
|
|
45
|
+
to = from - page;
|
|
46
|
+
break;
|
|
47
|
+
case "PageDown":
|
|
48
|
+
to = from + page;
|
|
49
|
+
break;
|
|
50
|
+
case "Home": return 0;
|
|
51
|
+
case "End": return max;
|
|
52
|
+
default: return null;
|
|
53
|
+
}
|
|
54
|
+
return Math.min(max, Math.max(0, to));
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=list-scroll-keys.js.map
|