Initial commit on Forgejo
All checks were successful
CI and deploy / Test (push) Successful in 4m52s
CI and deploy / Deploy production (push) Successful in 23s

Fresh repository history for elektrine/tarakan hosted at
https://git.elektrine.com/elektrine/tarakan.
This commit is contained in:
Maxfield Luke 2026-07-29 04:43:40 -04:00
commit af6077b9c3
455 changed files with 78366 additions and 0 deletions

377
assets/css/app.css Normal file
View 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
View 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
})
}

View 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
View 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
View 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
View 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

File diff suppressed because one or more lines are too long

138
assets/vendor/topbar.js vendored Normal file
View 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));