Initial commit on Forgejo
Fresh repository history for elektrine/tarakan hosted at https://git.elektrine.com/elektrine/tarakan.
This commit is contained in:
commit
af6077b9c3
455 changed files with 78366 additions and 0 deletions
377
assets/css/app.css
Normal file
377
assets/css/app.css
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
@import "tailwindcss" source(none);
|
||||
@import "phoenix-colocated/tarakan/colocated.css";
|
||||
@source "../css";
|
||||
@source "../js";
|
||||
@source "../../lib/tarakan_web";
|
||||
@source "../../_build/dev/phoenix-colocated/tarakan/*/";
|
||||
|
||||
@plugin "../vendor/heroicons";
|
||||
|
||||
@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &);
|
||||
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
|
||||
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
|
||||
|
||||
[data-phx-session],
|
||||
[data-phx-teleported-src] {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Tailwind's preflight leaves native buttons on the default arrow cursor.
|
||||
Make the interaction contract consistent without repeating cursor utilities
|
||||
on every component and LiveView action. */
|
||||
@layer base {
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
/* Prefer larger tap targets on coarse pointers without changing desktop. */
|
||||
@media (pointer: coarse) {
|
||||
button:not(:disabled),
|
||||
a,
|
||||
summary,
|
||||
[role="button"] {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
input:is([type="button"], [type="submit"], [type="reset"]):not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
input:is([type="button"], [type="submit"], [type="reset"]):disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
/* Chakra Petch (400/500/700), latin subset, vendored in priv/static/fonts */
|
||||
@font-face {
|
||||
font-family: "Chakra Petch";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/fonts/chakra-petch-latin-400.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Chakra Petch";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("/fonts/chakra-petch-latin-500.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Chakra Petch";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("/fonts/chakra-petch-latin-700.woff2") format("woff2");
|
||||
}
|
||||
|
||||
/* Tarakan color tokens.
|
||||
|
||||
Dark (flagship): pure void black, hard white type, signal red for findings
|
||||
and primary actions, amber for secondary callouts. Borders stay cold grey so
|
||||
red only hits where it means something (findings, CTAs, live pulse).
|
||||
Light: clinical paper, black ink, the same pure red under control. */
|
||||
:root {
|
||||
--ground: #f4f4f4;
|
||||
--panel: #ebebeb;
|
||||
--ink: #0a0a0a;
|
||||
--ink-muted: #444444;
|
||||
/* --ink-faint carries real telemetry (SHAs, timestamps, run counts) at 10-11px,
|
||||
so it has to clear WCAG AA against BOTH --ground and --panel, not just --ground.
|
||||
#666 = 5.2:1 on ground, 4.8:1 on panel. The old #777 was 4.1:1 / 3.8:1. */
|
||||
--ink-faint: #666666;
|
||||
--rule: #cccccc;
|
||||
--strong: #111111;
|
||||
--signal: #c40018;
|
||||
--quote: #9a6b00;
|
||||
--btn: #c40018;
|
||||
--btn-fg: #ffffff;
|
||||
/* The well is the surface live values sit on. Light keeps it visually flush
|
||||
with the ground - a dark slab reads as a defect on paper - but it must be
|
||||
an opaque paint, not transparent: wire-badge hollows fill with --well and
|
||||
would otherwise expose the badge's own stroke color. Dark gets the true
|
||||
phosphor treatment: recessed slab, scanlines, glow. */
|
||||
--well: #f4f4f4;
|
||||
--well-pad: 0;
|
||||
--phosphor: #c40018;
|
||||
--glow: none;
|
||||
--glow-soft: none;
|
||||
--scan: none;
|
||||
--live-edge: var(--signal);
|
||||
}
|
||||
|
||||
/* Dark tokens apply via OS preference, and via the manual toggle
|
||||
(data-theme). Keep the two dark blocks identical. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--ground: #000000;
|
||||
--panel: #0a0a0a;
|
||||
--ink: #f2f2f2;
|
||||
--ink-muted: #9a9a9a;
|
||||
--ink-faint: #7d7d7d;
|
||||
--rule: #1a1a1a;
|
||||
--strong: #2e2e2e;
|
||||
--signal: #e60012;
|
||||
--quote: #ffcc00;
|
||||
--btn: #e60012;
|
||||
--btn-fg: #ffffff;
|
||||
--well: #050505;
|
||||
--well-pad: 0.75rem;
|
||||
--phosphor: #ff1a2e;
|
||||
--live-edge: transparent;
|
||||
--glow: 0 0 12px color-mix(in srgb, var(--signal) 55%, transparent);
|
||||
--glow-soft: 0 0 8px color-mix(in srgb, var(--signal) 40%, transparent);
|
||||
--scan: repeating-linear-gradient(
|
||||
0deg,
|
||||
rgb(255 255 255 / 0.03) 0 1px,
|
||||
transparent 1px 3px
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--ground: #000000;
|
||||
--panel: #0a0a0a;
|
||||
--ink: #f2f2f2;
|
||||
--ink-muted: #9a9a9a;
|
||||
--ink-faint: #7d7d7d;
|
||||
--rule: #1a1a1a;
|
||||
--strong: #2e2e2e;
|
||||
--signal: #e60012;
|
||||
--quote: #ffcc00;
|
||||
--btn: #e60012;
|
||||
--btn-fg: #ffffff;
|
||||
--well: #050505;
|
||||
--well-pad: 0.75rem;
|
||||
--phosphor: #ff1a2e;
|
||||
--live-edge: transparent;
|
||||
--glow: 0 0 12px color-mix(in srgb, var(--signal) 55%, transparent);
|
||||
--glow-soft: 0 0 8px color-mix(in srgb, var(--signal) 40%, transparent);
|
||||
--scan: repeating-linear-gradient(
|
||||
0deg,
|
||||
rgb(255 255 255 / 0.03) 0 1px,
|
||||
transparent 1px 3px
|
||||
);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
--ground: #f4f4f4;
|
||||
--panel: #ebebeb;
|
||||
--ink: #0a0a0a;
|
||||
--ink-muted: #444444;
|
||||
--ink-faint: #666666;
|
||||
--rule: #cccccc;
|
||||
--strong: #111111;
|
||||
--signal: #c40018;
|
||||
--quote: #9a6b00;
|
||||
--btn: #c40018;
|
||||
--btn-fg: #ffffff;
|
||||
--well: #f4f4f4;
|
||||
--well-pad: 0;
|
||||
--phosphor: #c40018;
|
||||
--glow: none;
|
||||
--glow-soft: none;
|
||||
--scan: none;
|
||||
--live-edge: var(--signal);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-ground: var(--ground);
|
||||
--color-panel: var(--panel);
|
||||
--color-ink: var(--ink);
|
||||
--color-ink-muted: var(--ink-muted);
|
||||
--color-ink-faint: var(--ink-faint);
|
||||
--color-rule: var(--rule);
|
||||
--color-strong: var(--strong);
|
||||
--color-signal: var(--signal);
|
||||
--color-quote: var(--quote);
|
||||
--color-btn: var(--btn);
|
||||
--color-btn-fg: var(--btn-fg);
|
||||
--color-well: var(--well);
|
||||
--color-phosphor: var(--phosphor);
|
||||
--font-display: "Chakra Petch", "Arial", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
@utility clip-notch {
|
||||
/* Filled CTAs only (bg-btn etc.). Clip alone - solid fill defines the edge.
|
||||
Outline badges must use .wire-badge instead (border + clip-path eats strokes). */
|
||||
clip-path: polygon(
|
||||
0 0,
|
||||
calc(100% - 0.5rem) 0,
|
||||
100% 0.5rem,
|
||||
100% 100%,
|
||||
0.5rem 100%,
|
||||
0 calc(100% - 0.5rem)
|
||||
);
|
||||
}
|
||||
|
||||
@utility clip-notch-sm {
|
||||
/* Filled small controls only. Outline badges: use .wire-badge. */
|
||||
clip-path: polygon(0 0, calc(100% - 5px) 0, 100% 5px, 100% 100%, 0 100%);
|
||||
}
|
||||
|
||||
/* The newest wire entry sits on the well and reads in phosphor - the visible
|
||||
proof that the record is live. Scanlines are part of the well surface and
|
||||
disappear with it in light. Unlayered so it wins over utilities. */
|
||||
#activity-wire {
|
||||
--wire-badge-fill: var(--ground);
|
||||
}
|
||||
|
||||
/* Every row reserves the edge so live and stale rows stay aligned; only the
|
||||
live row paints it. In dark --live-edge is transparent because the well slab
|
||||
and the glow already say "live"; in light they both flatten to nothing, so
|
||||
the red edge is the only surface cue there is. */
|
||||
#activity-wire article,
|
||||
#shoutbox-messages article {
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
|
||||
#activity-wire article:first-of-type {
|
||||
background-color: var(--well);
|
||||
background-image: var(--scan);
|
||||
border-left-color: var(--live-edge);
|
||||
/* Hollow badge interiors must match the well, not the page ground. */
|
||||
--wire-badge-fill: var(--well);
|
||||
}
|
||||
|
||||
#activity-wire article:first-of-type :is(span, time, a) {
|
||||
color: var(--phosphor);
|
||||
text-shadow: var(--glow-soft);
|
||||
}
|
||||
|
||||
#activity-wire article:first-of-type .wire-badge {
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
/* The newest shout gets the same live treatment as the wire's newest entry.
|
||||
The log reads chronologically, so the newest message is the LAST article.
|
||||
Only the provenance line (handle + time) glows - shout bodies are user
|
||||
prose, not telemetry, and stay in ink. */
|
||||
#shoutbox-messages article:last-of-type {
|
||||
background-color: var(--well);
|
||||
background-image: var(--scan);
|
||||
border-left-color: var(--live-edge);
|
||||
}
|
||||
|
||||
#shoutbox-messages article:last-of-type :is(time, a) {
|
||||
color: var(--phosphor);
|
||||
text-shadow: var(--glow-soft);
|
||||
}
|
||||
|
||||
/* Default hole colour for outline notched badges. Override on non-ground
|
||||
surfaces (e.g. #activity-wire article:first-of-type sets --well). */
|
||||
:root {
|
||||
--wire-badge-fill: var(--ground);
|
||||
}
|
||||
|
||||
.bg-panel {
|
||||
--wire-badge-fill: var(--panel);
|
||||
}
|
||||
|
||||
/* Notched badge with a continuous stroke (top + diagonal included).
|
||||
Never use border + clip-path for outlines - clip-path eats the stroke.
|
||||
Shell = currentColor; hole = --wire-badge-fill; label above. */
|
||||
.wire-badge {
|
||||
--wire-notch: 5px;
|
||||
--wire-stroke: 2px;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: none !important;
|
||||
background-color: currentColor;
|
||||
clip-path: polygon(
|
||||
0 0,
|
||||
calc(100% - var(--wire-notch)) 0,
|
||||
100% var(--wire-notch),
|
||||
100% 100%,
|
||||
0 100%
|
||||
);
|
||||
padding: var(--wire-stroke);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.wire-badge::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: var(--wire-stroke);
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background: var(--wire-badge-fill, var(--ground));
|
||||
clip-path: polygon(
|
||||
0 0,
|
||||
calc(100% - max(0px, var(--wire-notch) - var(--wire-stroke))) 0,
|
||||
100% max(0px, var(--wire-notch) - var(--wire-stroke)),
|
||||
100% 100%,
|
||||
0 100%
|
||||
);
|
||||
}
|
||||
|
||||
.wire-badge-label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: inline-block;
|
||||
padding-inline: 0.375rem;
|
||||
padding-block: 0.125rem;
|
||||
color: inherit;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
/* Solid filled notched badges: slab only, no hollow. */
|
||||
.wire-badge.bg-ink {
|
||||
background-color: var(--ink) !important;
|
||||
color: var(--ground);
|
||||
}
|
||||
|
||||
.wire-badge.bg-signal {
|
||||
background-color: var(--signal) !important;
|
||||
color: var(--ground);
|
||||
}
|
||||
|
||||
.wire-badge.bg-btn {
|
||||
background-color: var(--btn) !important;
|
||||
color: var(--btn-fg);
|
||||
}
|
||||
|
||||
.wire-badge.bg-ink::before,
|
||||
.wire-badge.bg-signal::before,
|
||||
.wire-badge.bg-btn::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wire-badge.bg-ink .wire-badge-label,
|
||||
.wire-badge.bg-signal .wire-badge-label {
|
||||
color: var(--ground);
|
||||
}
|
||||
|
||||
.wire-badge.bg-btn .wire-badge-label {
|
||||
color: var(--btn-fg);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Theme toggle: the pressed appearance is derived from the same source of
|
||||
truth as the colours themselves - the `data-theme` attribute on <html> -
|
||||
rather than from `aria-pressed`, which JavaScript can only set after the
|
||||
page has already painted. The server has no idea which theme a visitor
|
||||
chose, so it renders every button unpressed; without this the toggle
|
||||
visibly snapped to the right option a moment after load.
|
||||
|
||||
Absent attribute means "follow the OS", which is the default, so that case
|
||||
needs no script at all. `aria-pressed` is still synced in JS because screen
|
||||
readers are unaffected by a paint-time flash. */
|
||||
:root:not([data-theme]) [data-theme-option="system"],
|
||||
:root[data-theme="light"] [data-theme-option="light"],
|
||||
:root[data-theme="dark"] [data-theme-option="dark"] {
|
||||
background-color: var(--panel);
|
||||
color: var(--ink);
|
||||
}
|
||||
223
assets/js/app.js
Normal file
223
assets/js/app.js
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
|
||||
// to get started and then uncomment the line below.
|
||||
// import "./user_socket.js"
|
||||
|
||||
// You can include dependencies in two ways.
|
||||
//
|
||||
// The simplest option is to put them in assets/vendor and
|
||||
// import them using relative paths:
|
||||
//
|
||||
// import "../vendor/some-package.js"
|
||||
//
|
||||
// Alternatively, you can `npm install some-package --prefix assets` and import
|
||||
// them using a path starting with the package name:
|
||||
//
|
||||
// import "some-package"
|
||||
//
|
||||
// If you have dependencies that try to import CSS, esbuild will generate a separate `app.css` file.
|
||||
// To load it, simply add a second `<link>` to your `root.html.heex` file.
|
||||
|
||||
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
|
||||
import "phoenix_html"
|
||||
|
||||
// Theme: no data-theme attribute means "follow the OS"; a manual choice is
|
||||
// stamped on <html> and persisted.
|
||||
const syncThemeToggle = theme => {
|
||||
document.querySelectorAll("[data-theme-option]").forEach(button => {
|
||||
button.setAttribute("aria-pressed", button.dataset.themeOption === theme ? "true" : "false")
|
||||
})
|
||||
}
|
||||
|
||||
const setTheme = theme => {
|
||||
if (theme === "system") {
|
||||
localStorage.removeItem("tarakan:theme")
|
||||
document.documentElement.removeAttribute("data-theme")
|
||||
} else {
|
||||
localStorage.setItem("tarakan:theme", theme)
|
||||
document.documentElement.setAttribute("data-theme", theme)
|
||||
}
|
||||
|
||||
syncThemeToggle(theme)
|
||||
}
|
||||
setTheme(localStorage.getItem("tarakan:theme") || "system")
|
||||
window.addEventListener("tarakan:set-theme", ({detail}) => setTheme(detail.theme))
|
||||
// Establish Phoenix Socket and LiveView configuration.
|
||||
import {Socket} from "phoenix"
|
||||
import {LiveSocket} from "phoenix_live_view"
|
||||
import {hooks as colocatedHooks} from "phoenix-colocated/tarakan"
|
||||
import topbar from "../vendor/topbar"
|
||||
import InfestationField from "./hooks/infestation_field.js"
|
||||
|
||||
const AutoDismiss = {
|
||||
mounted() {
|
||||
this.pause = () => this.clearTimer()
|
||||
this.resume = () => this.schedule()
|
||||
this.el.addEventListener("mouseenter", this.pause)
|
||||
this.el.addEventListener("mouseleave", this.resume)
|
||||
this.el.addEventListener("focusin", this.pause)
|
||||
this.el.addEventListener("focusout", this.resume)
|
||||
this.schedule()
|
||||
},
|
||||
|
||||
updated() {
|
||||
this.schedule()
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
this.clearTimer()
|
||||
this.el.removeEventListener("mouseenter", this.pause)
|
||||
this.el.removeEventListener("mouseleave", this.resume)
|
||||
this.el.removeEventListener("focusin", this.pause)
|
||||
this.el.removeEventListener("focusout", this.resume)
|
||||
},
|
||||
|
||||
clearTimer() {
|
||||
if (this.timer) window.clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
},
|
||||
|
||||
schedule() {
|
||||
this.clearTimer()
|
||||
const delay = Number(this.el.dataset.autoDismissMs || 5000)
|
||||
this.timer = window.setTimeout(() => this.el.click(), delay)
|
||||
},
|
||||
}
|
||||
|
||||
const PinToBottom = {
|
||||
mounted() {
|
||||
this.scrollToBottom()
|
||||
},
|
||||
|
||||
beforeUpdate() {
|
||||
const distance = this.el.scrollHeight - this.el.scrollTop - this.el.clientHeight
|
||||
this.pinned = distance < 60
|
||||
},
|
||||
|
||||
updated() {
|
||||
if (this.pinned) this.scrollToBottom()
|
||||
},
|
||||
|
||||
scrollToBottom() {
|
||||
this.el.scrollTop = this.el.scrollHeight
|
||||
},
|
||||
}
|
||||
|
||||
const SearchShortcut = {
|
||||
mounted() {
|
||||
this.onKeydown = event => {
|
||||
if (event.key !== "/" || event.defaultPrevented) return
|
||||
const target = event.target
|
||||
if (target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) return
|
||||
event.preventDefault()
|
||||
this.el.focus()
|
||||
}
|
||||
window.addEventListener("keydown", this.onKeydown)
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
window.removeEventListener("keydown", this.onKeydown)
|
||||
},
|
||||
}
|
||||
|
||||
const CopyLink = {
|
||||
mounted() {
|
||||
this.onClick = async () => {
|
||||
const text = this.el.dataset.copyText
|
||||
if (!text) return
|
||||
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} else {
|
||||
const ta = document.createElement("textarea")
|
||||
ta.value = text
|
||||
ta.setAttribute("readonly", "")
|
||||
ta.style.position = "fixed"
|
||||
ta.style.left = "-9999px"
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand("copy")
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
|
||||
const label = this.el.querySelector("[data-copy-label]")
|
||||
if (label) {
|
||||
const previous = label.textContent
|
||||
label.textContent = this.el.dataset.copiedLabel || "Copied"
|
||||
window.clearTimeout(this.resetTimer)
|
||||
this.resetTimer = window.setTimeout(() => {
|
||||
label.textContent = previous
|
||||
}, 1600)
|
||||
}
|
||||
} catch (_err) {
|
||||
// Silent: clipboard may be blocked; the visible cite URL remains.
|
||||
}
|
||||
}
|
||||
|
||||
this.el.addEventListener("click", this.onClick)
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
this.el.removeEventListener("click", this.onClick)
|
||||
window.clearTimeout(this.resetTimer)
|
||||
},
|
||||
}
|
||||
|
||||
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||
const liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: {_csrf_token: csrfToken},
|
||||
hooks: {...colocatedHooks, AutoDismiss, PinToBottom, SearchShortcut, CopyLink, InfestationField},
|
||||
})
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
topbar.config({barColors: {0: "#e60012"}, shadowColor: "rgba(0, 0, 0, .35)"})
|
||||
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
|
||||
window.addEventListener("phx:page-loading-stop", _info => {
|
||||
topbar.hide()
|
||||
syncThemeToggle(localStorage.getItem("tarakan:theme") || "system")
|
||||
})
|
||||
|
||||
// connect if there are any LiveViews on the page
|
||||
liveSocket.connect()
|
||||
|
||||
// expose liveSocket on window for web console debug logs and latency simulation:
|
||||
// >> liveSocket.enableDebug()
|
||||
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
|
||||
// >> liveSocket.disableLatencySim()
|
||||
window.liveSocket = liveSocket
|
||||
|
||||
// The lines below enable quality of life phoenix_live_reload
|
||||
// development features:
|
||||
//
|
||||
// 1. stream server logs to the browser console
|
||||
// 2. click on elements to jump to their definitions in your code editor
|
||||
//
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => {
|
||||
// Enable server log streaming to client.
|
||||
// Disable with reloader.disableServerLogs()
|
||||
reloader.enableServerLogs()
|
||||
|
||||
// Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component
|
||||
//
|
||||
// * click with "c" key pressed to open at caller location
|
||||
// * click with "d" key pressed to open at function component definition location
|
||||
let keyDown
|
||||
window.addEventListener("keydown", e => keyDown = e.key)
|
||||
window.addEventListener("keyup", _e => keyDown = null)
|
||||
window.addEventListener("click", e => {
|
||||
if(keyDown === "c"){
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
reloader.openEditorAtCaller(e.target)
|
||||
} else if(keyDown === "d"){
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
reloader.openEditorAtDef(e.target)
|
||||
}
|
||||
}, true)
|
||||
|
||||
window.liveReloader = reloader
|
||||
})
|
||||
}
|
||||
292
assets/js/hooks/infestation_field.js
Normal file
292
assets/js/hooks/infestation_field.js
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
// A slowly drifting wireframe of the cross-repository graph: one point per
|
||||
// repository that carries a multi-repo pattern, one line for every pattern two
|
||||
// repositories share. Rendered rather than tabulated because entanglement is
|
||||
// the one shape on the record a table cannot show.
|
||||
//
|
||||
// Deliberately flat and line-based - no lighting, no materials, no depth fog -
|
||||
// so it reads as an oscilloscope trace rather than a product render.
|
||||
//
|
||||
// Degrades to nothing: no WebGL, no data, or reduced-motion preference all
|
||||
// leave the surrounding markup untouched, which already carries the real
|
||||
// numbers.
|
||||
|
||||
const POINT_SIZE = 5.5
|
||||
const RADIUS = 26
|
||||
const DRIFT = 0.00035
|
||||
|
||||
export default {
|
||||
mounted() {
|
||||
this.start()
|
||||
},
|
||||
|
||||
updated() {
|
||||
// The graph is re-sent on registry changes; rebuild rather than mutate.
|
||||
this.stop()
|
||||
this.start()
|
||||
},
|
||||
|
||||
// three.js is ~132 KB gzipped - four times the rest of the bundle combined.
|
||||
// Importing it dynamically keeps it in its own chunk, so only a visitor who
|
||||
// actually reaches a page with the field pays for it, and every other page
|
||||
// loads the same 43 KB it always did.
|
||||
async loadThree() {
|
||||
if (!window.__tarakanThree) {
|
||||
window.__tarakanThree = import("../../vendor/three.module.js")
|
||||
}
|
||||
return window.__tarakanThree
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
this.stop()
|
||||
},
|
||||
|
||||
async start() {
|
||||
const graph = this.readGraph()
|
||||
if (!graph || graph.nodes.length < 2) return
|
||||
|
||||
let THREE
|
||||
try {
|
||||
THREE = await this.loadThree()
|
||||
} catch (_error) {
|
||||
return
|
||||
}
|
||||
|
||||
// The hook can be destroyed while the chunk is in flight.
|
||||
if (!this.el.isConnected) return
|
||||
this.THREE = THREE
|
||||
|
||||
this.graph = graph
|
||||
this.draw()
|
||||
|
||||
// The field is hidden below the lg breakpoint, so at mount it can have no
|
||||
// size at all and there is nothing to render into. Watch for it gaining
|
||||
// one instead of giving up permanently.
|
||||
if (!this.renderer && "ResizeObserver" in window) {
|
||||
this.observer = new ResizeObserver(() => {
|
||||
if (!this.renderer && this.el.clientWidth > 0 && this.el.clientHeight > 0) {
|
||||
this.draw()
|
||||
}
|
||||
})
|
||||
this.observer.observe(this.el)
|
||||
}
|
||||
},
|
||||
|
||||
draw() {
|
||||
if (!this.graph || !this.THREE) return
|
||||
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
this.renderStill(this.graph)
|
||||
} else {
|
||||
this.renderAnimated(this.graph)
|
||||
}
|
||||
},
|
||||
|
||||
readGraph() {
|
||||
try {
|
||||
const raw = this.el.dataset.graph
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed.nodes) || !Array.isArray(parsed.edges)) return null
|
||||
return parsed
|
||||
} catch (_error) {
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
// Colours come from the stylesheet so the field follows the theme toggle
|
||||
// instead of hard-coding a palette that only works in dark mode.
|
||||
token(name, fallback) {
|
||||
const value = getComputedStyle(this.el).getPropertyValue(name).trim()
|
||||
return value || fallback
|
||||
},
|
||||
|
||||
build(graph) {
|
||||
const { nodes, edges } = graph
|
||||
|
||||
// Fibonacci sphere: even coverage without clustering at the poles, and
|
||||
// deterministic, so the same record always draws the same figure.
|
||||
const positions = nodes.map((_node, i) => {
|
||||
const offset = 2 / nodes.length
|
||||
const y = i * offset - 1 + offset / 2
|
||||
const r = Math.sqrt(Math.max(0, 1 - y * y))
|
||||
const phi = i * Math.PI * (3 - Math.sqrt(5))
|
||||
return [Math.cos(phi) * r * RADIUS, y * RADIUS, Math.sin(phi) * r * RADIUS]
|
||||
})
|
||||
|
||||
const group = new this.THREE.Group()
|
||||
|
||||
const pointGeometry = new this.THREE.BufferGeometry()
|
||||
pointGeometry.setAttribute(
|
||||
"position",
|
||||
new this.THREE.Float32BufferAttribute(positions.flat(), 3)
|
||||
)
|
||||
group.add(
|
||||
new this.THREE.Points(
|
||||
pointGeometry,
|
||||
new this.THREE.PointsMaterial({
|
||||
color: new this.THREE.Color(this.token("--color-ink-faint", "#8a8f93")),
|
||||
size: POINT_SIZE,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.9
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
if (edges.length > 0) {
|
||||
const linePoints = []
|
||||
edges.forEach(([a, b]) => {
|
||||
if (positions[a] && positions[b]) linePoints.push(...positions[a], ...positions[b])
|
||||
})
|
||||
|
||||
if (linePoints.length > 0) {
|
||||
const lineGeometry = new this.THREE.BufferGeometry()
|
||||
lineGeometry.setAttribute(
|
||||
"position",
|
||||
new this.THREE.Float32BufferAttribute(linePoints, 3)
|
||||
)
|
||||
group.add(
|
||||
new this.THREE.LineSegments(
|
||||
lineGeometry,
|
||||
new this.THREE.LineBasicMaterial({
|
||||
color: new this.THREE.Color(this.token("--color-phosphor", "#4ade80")),
|
||||
transparent: true,
|
||||
opacity: 0.42
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return group
|
||||
},
|
||||
|
||||
setup(graph) {
|
||||
const width = this.el.clientWidth
|
||||
const height = this.el.clientHeight
|
||||
if (width === 0 || height === 0) return null
|
||||
|
||||
let renderer
|
||||
try {
|
||||
renderer = new this.THREE.WebGLRenderer({ alpha: true, antialias: true })
|
||||
} catch (_error) {
|
||||
return null
|
||||
}
|
||||
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||
renderer.setSize(width, height, false)
|
||||
renderer.domElement.setAttribute("aria-hidden", "true")
|
||||
this.el.appendChild(renderer.domElement)
|
||||
|
||||
const scene = new this.THREE.Scene()
|
||||
const camera = new this.THREE.PerspectiveCamera(45, width / height, 1, 400)
|
||||
camera.position.z = 78
|
||||
|
||||
const group = this.build(graph)
|
||||
scene.add(group)
|
||||
|
||||
this.renderer = renderer
|
||||
this.scene = scene
|
||||
this.camera = camera
|
||||
this.group = group
|
||||
|
||||
this.onResize = () => {
|
||||
const w = this.el.clientWidth
|
||||
const h = this.el.clientHeight
|
||||
if (w === 0 || h === 0) return
|
||||
camera.aspect = w / h
|
||||
camera.updateProjectionMatrix()
|
||||
renderer.setSize(w, h, false)
|
||||
renderer.render(scene, camera)
|
||||
}
|
||||
window.addEventListener("resize", this.onResize)
|
||||
|
||||
return { renderer, scene, camera, group }
|
||||
},
|
||||
|
||||
renderStill(graph) {
|
||||
const context = this.setup(graph)
|
||||
if (!context) return
|
||||
context.group.rotation.set(0.35, 0.6, 0)
|
||||
context.renderer.render(context.scene, context.camera)
|
||||
},
|
||||
|
||||
renderAnimated(graph) {
|
||||
const context = this.setup(graph)
|
||||
if (!context) return
|
||||
|
||||
const { renderer, scene, camera, group } = context
|
||||
let last = performance.now()
|
||||
|
||||
const frame = (now) => {
|
||||
// Rotation is time-based, not frame-based, so a 120Hz display does not
|
||||
// spin twice as fast as a 60Hz one.
|
||||
const delta = now - last
|
||||
last = now
|
||||
group.rotation.y += DRIFT * delta
|
||||
group.rotation.x = Math.sin(now * 0.00007) * 0.22
|
||||
renderer.render(scene, camera)
|
||||
this.frameId = requestAnimationFrame(frame)
|
||||
}
|
||||
|
||||
this.frameId = requestAnimationFrame(frame)
|
||||
|
||||
// A hidden tab still fires rAF in some browsers; stop drawing when the
|
||||
// page is not visible so a background tab costs nothing.
|
||||
this.onVisibility = () => {
|
||||
if (document.hidden) {
|
||||
this.cancelFrame()
|
||||
} else if (!this.frameId) {
|
||||
last = performance.now()
|
||||
this.frameId = requestAnimationFrame(frame)
|
||||
}
|
||||
}
|
||||
document.addEventListener("visibilitychange", this.onVisibility)
|
||||
},
|
||||
|
||||
cancelFrame() {
|
||||
if (this.frameId) {
|
||||
cancelAnimationFrame(this.frameId)
|
||||
this.frameId = null
|
||||
}
|
||||
},
|
||||
|
||||
stop() {
|
||||
this.cancelFrame()
|
||||
|
||||
if (this.observer) {
|
||||
this.observer.disconnect()
|
||||
this.observer = null
|
||||
}
|
||||
|
||||
if (this.onResize) {
|
||||
window.removeEventListener("resize", this.onResize)
|
||||
this.onResize = null
|
||||
}
|
||||
|
||||
if (this.onVisibility) {
|
||||
document.removeEventListener("visibilitychange", this.onVisibility)
|
||||
this.onVisibility = null
|
||||
}
|
||||
|
||||
// GPU buffers are not garbage collected with the JS objects that own them,
|
||||
// and LiveView navigation destroys hooks constantly.
|
||||
if (this.group) {
|
||||
this.group.traverse((child) => {
|
||||
if (child.geometry) child.geometry.dispose()
|
||||
if (child.material) child.material.dispose()
|
||||
})
|
||||
this.group = null
|
||||
}
|
||||
|
||||
if (this.renderer) {
|
||||
this.renderer.dispose()
|
||||
this.renderer.domElement.remove()
|
||||
this.renderer = null
|
||||
}
|
||||
|
||||
this.scene = null
|
||||
this.camera = null
|
||||
this.graph = null
|
||||
}
|
||||
}
|
||||
21
assets/js/theme.js
Normal file
21
assets/js/theme.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// Runs blocking in <head>, before the first paint.
|
||||
//
|
||||
// app.js is a deferred module, so anything it does happens after the browser
|
||||
// has already drawn the page - which is why the theme toggle used to flash the
|
||||
// wrong option on load, and why a manually chosen theme would show a frame of
|
||||
// the OS one first. This has to stay a classic script with no defer/async, and
|
||||
// it has to stay small enough that blocking on it is free.
|
||||
//
|
||||
// Absent attribute means "follow the OS", so the only work here is restoring a
|
||||
// manual choice. Everything else - the pressed styling, the toggle handlers -
|
||||
// is CSS and app.js respectively.
|
||||
(function () {
|
||||
try {
|
||||
var theme = localStorage.getItem("tarakan:theme")
|
||||
if (theme === "light" || theme === "dark") {
|
||||
document.documentElement.setAttribute("data-theme", theme)
|
||||
}
|
||||
} catch (_error) {
|
||||
// Private mode or blocked storage: fall through to the OS preference.
|
||||
}
|
||||
})()
|
||||
32
assets/tsconfig.json
Normal file
32
assets/tsconfig.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// This file is needed on most editors to enable the intelligent autocompletion
|
||||
// of LiveView's JavaScript API methods. You can safely delete it if you don't need it.
|
||||
//
|
||||
// Note: This file assumes a basic esbuild setup without node_modules.
|
||||
// We include a generic paths alias to deps to mimic how esbuild resolves
|
||||
// the Phoenix and LiveView JavaScript assets.
|
||||
// If you have a package.json in your project, you should remove the
|
||||
// paths configuration and instead add the phoenix dependencies to the
|
||||
// dependencies section of your package.json:
|
||||
//
|
||||
// {
|
||||
// ...
|
||||
// "dependencies": {
|
||||
// ...,
|
||||
// "phoenix": "../deps/phoenix",
|
||||
// "phoenix_html": "../deps/phoenix_html",
|
||||
// "phoenix_live_view": "../deps/phoenix_live_view"
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Feel free to adjust this configuration however you need.
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"*": ["../deps/*"]
|
||||
},
|
||||
"allowJs": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["js/**/*"]
|
||||
}
|
||||
43
assets/vendor/heroicons.js
vendored
Normal file
43
assets/vendor/heroicons.js
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
const plugin = require("tailwindcss/plugin")
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
module.exports = plugin(function({matchComponents, theme}) {
|
||||
let iconsDir = path.join(__dirname, "../../deps/heroicons/optimized")
|
||||
let values = {}
|
||||
let icons = [
|
||||
["", "/24/outline"],
|
||||
["-solid", "/24/solid"],
|
||||
["-mini", "/20/solid"],
|
||||
["-micro", "/16/solid"]
|
||||
]
|
||||
icons.forEach(([suffix, dir]) => {
|
||||
fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
|
||||
let name = path.basename(file, ".svg") + suffix
|
||||
values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
|
||||
})
|
||||
})
|
||||
matchComponents({
|
||||
"hero": ({name, fullPath}) => {
|
||||
let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
|
||||
content = encodeURIComponent(content)
|
||||
let size = theme("spacing.6")
|
||||
if (name.endsWith("-mini")) {
|
||||
size = theme("spacing.5")
|
||||
} else if (name.endsWith("-micro")) {
|
||||
size = theme("spacing.4")
|
||||
}
|
||||
return {
|
||||
[`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
|
||||
"-webkit-mask": `var(--hero-${name})`,
|
||||
"mask": `var(--hero-${name})`,
|
||||
"mask-repeat": "no-repeat",
|
||||
"background-color": "currentColor",
|
||||
"vertical-align": "middle",
|
||||
"display": "inline-block",
|
||||
"width": size,
|
||||
"height": size
|
||||
}
|
||||
}
|
||||
}, {values})
|
||||
})
|
||||
6
assets/vendor/three.module.js
vendored
Normal file
6
assets/vendor/three.module.js
vendored
Normal file
File diff suppressed because one or more lines are too long
138
assets/vendor/topbar.js
vendored
Normal file
138
assets/vendor/topbar.js
vendored
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
/**
|
||||
* @license MIT
|
||||
* topbar 3.0.0
|
||||
* http://buunguyen.github.io/topbar
|
||||
* Copyright (c) 2024 Buu Nguyen
|
||||
*/
|
||||
(function (window, document) {
|
||||
"use strict";
|
||||
|
||||
var canvas,
|
||||
currentProgress,
|
||||
showing,
|
||||
progressTimerId = null,
|
||||
fadeTimerId = null,
|
||||
delayTimerId = null,
|
||||
addEvent = function (elem, type, handler) {
|
||||
if (elem.addEventListener) elem.addEventListener(type, handler, false);
|
||||
else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
|
||||
else elem["on" + type] = handler;
|
||||
},
|
||||
options = {
|
||||
autoRun: true,
|
||||
barThickness: 3,
|
||||
barColors: {
|
||||
0: "rgba(26, 188, 156, .9)",
|
||||
".25": "rgba(52, 152, 219, .9)",
|
||||
".50": "rgba(241, 196, 15, .9)",
|
||||
".75": "rgba(230, 126, 34, .9)",
|
||||
"1.0": "rgba(211, 84, 0, .9)",
|
||||
},
|
||||
shadowBlur: 10,
|
||||
shadowColor: "rgba(0, 0, 0, .6)",
|
||||
className: null,
|
||||
},
|
||||
repaint = function () {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = options.barThickness * 5; // need space for shadow
|
||||
|
||||
var ctx = canvas.getContext("2d");
|
||||
ctx.shadowBlur = options.shadowBlur;
|
||||
ctx.shadowColor = options.shadowColor;
|
||||
|
||||
var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
|
||||
for (var stop in options.barColors)
|
||||
lineGradient.addColorStop(stop, options.barColors[stop]);
|
||||
ctx.lineWidth = options.barThickness;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, options.barThickness / 2);
|
||||
ctx.lineTo(
|
||||
Math.ceil(currentProgress * canvas.width),
|
||||
options.barThickness / 2
|
||||
);
|
||||
ctx.strokeStyle = lineGradient;
|
||||
ctx.stroke();
|
||||
},
|
||||
createCanvas = function () {
|
||||
canvas = document.createElement("canvas");
|
||||
var style = canvas.style;
|
||||
style.position = "fixed";
|
||||
style.top = style.left = style.right = style.margin = style.padding = 0;
|
||||
style.zIndex = 100001;
|
||||
style.display = "none";
|
||||
if (options.className) canvas.classList.add(options.className);
|
||||
addEvent(window, "resize", repaint);
|
||||
},
|
||||
topbar = {
|
||||
config: function (opts) {
|
||||
for (var key in opts)
|
||||
if (options.hasOwnProperty(key)) options[key] = opts[key];
|
||||
},
|
||||
show: function (delay) {
|
||||
if (showing) return;
|
||||
if (delay) {
|
||||
if (delayTimerId) return;
|
||||
delayTimerId = setTimeout(() => topbar.show(), delay);
|
||||
} else {
|
||||
showing = true;
|
||||
if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
|
||||
if (!canvas) createCanvas();
|
||||
if (!canvas.parentElement) document.body.appendChild(canvas);
|
||||
canvas.style.opacity = 1;
|
||||
canvas.style.display = "block";
|
||||
topbar.progress(0);
|
||||
if (options.autoRun) {
|
||||
(function loop() {
|
||||
progressTimerId = window.requestAnimationFrame(loop);
|
||||
topbar.progress(
|
||||
"+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
|
||||
);
|
||||
})();
|
||||
}
|
||||
}
|
||||
},
|
||||
progress: function (to) {
|
||||
if (typeof to === "undefined") return currentProgress;
|
||||
if (typeof to === "string") {
|
||||
to =
|
||||
(to.indexOf("+") >= 0 || to.indexOf("-") >= 0
|
||||
? currentProgress
|
||||
: 0) + parseFloat(to);
|
||||
}
|
||||
currentProgress = to > 1 ? 1 : to;
|
||||
repaint();
|
||||
return currentProgress;
|
||||
},
|
||||
hide: function () {
|
||||
clearTimeout(delayTimerId);
|
||||
delayTimerId = null;
|
||||
if (!showing) return;
|
||||
showing = false;
|
||||
if (progressTimerId != null) {
|
||||
window.cancelAnimationFrame(progressTimerId);
|
||||
progressTimerId = null;
|
||||
}
|
||||
(function loop() {
|
||||
if (topbar.progress("+.1") >= 1) {
|
||||
canvas.style.opacity -= 0.05;
|
||||
if (canvas.style.opacity <= 0.05) {
|
||||
canvas.style.display = "none";
|
||||
fadeTimerId = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
fadeTimerId = window.requestAnimationFrame(loop);
|
||||
})();
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module === "object" && typeof module.exports === "object") {
|
||||
module.exports = topbar;
|
||||
} else if (typeof define === "function" && define.amd) {
|
||||
define(function () {
|
||||
return topbar;
|
||||
});
|
||||
} else {
|
||||
this.topbar = topbar;
|
||||
}
|
||||
}.call(this, window, document));
|
||||
Loading…
Add table
Add a link
Reference in a new issue