/** * hired.tools — the page's behaviour. * * Rules this file keeps, because the product keeps them: * * 1. Nothing here is required to read the page. Every string that gets typed, * every element that fades in and every number that counts up is already in * the HTML. The `js` class added in the document head is what opts an * element into being hidden first — so with scripting off, or before this * file arrives, the page is simply finished. * 2. Nothing loops. Animations play once, on entry, and then hold. * 3. Anyone who has asked their system to calm down gets a still page. The * stylesheet neutralises the transitions; `calm` below skips the work. */ (function () { "use strict"; var calm = window.matchMedia("(prefers-reduced-motion: reduce)").matches; var $ = function (sel, root) { return (root || document).querySelector(sel); }; var $$ = function (sel, root) { return Array.prototype.slice.call((root || document).querySelectorAll(sel)); }; /** Run fn the first time el crosses into view, then stop watching it. */ function once(el, fn, margin) { if (!("IntersectionObserver" in window)) { fn(el); return; } var io = new IntersectionObserver( function (entries) { entries.forEach(function (entry) { if (!entry.isIntersecting) return; io.unobserve(entry.target); fn(entry.target); }); }, { rootMargin: margin || "0px 0px -12% 0px", threshold: 0.15 } ); io.observe(el); } // ------------------------------------------------------------------------- // One pointer, one frame // // A dozen things on this page answer the cursor: two marks, the bento cards, // the primary buttons, the two files. Each used to attach its own // `pointermove` and read a bounding box inside it, and a read that happens // after somebody else's write is a forced layout — so moving the mouse cost // twelve of them, every event, which is exactly the kind of thing that makes // a page feel like it is dragging. // // They share one listener and one frame now: every rect is read together, // then every style is written together. One layout per frame, whatever is // watching. // ------------------------------------------------------------------------- var watchers = []; var cursor = { x: 0, y: 0, near: false }; var pointerFrame = 0; function runWatchers() { pointerFrame = 0; // Reads first, all of them. for (var i = 0; i < watchers.length; i++) { watchers[i].box = watchers[i].el.getBoundingClientRect(); } // Then the writes, which cannot invalidate a read that has already happened. for (var j = 0; j < watchers.length; j++) { watchers[j].apply(watchers[j].box, cursor); } } function schedule() { if (!pointerFrame) pointerFrame = requestAnimationFrame(runWatchers); } /** Answer the pointer. `apply` is handed the element's rect and the cursor. */ function follows(el, apply) { if (calm) return; watchers.push({ el: el, apply: apply, box: null }); if (watchers.length === 1) { window.addEventListener( "pointermove", function (event) { cursor.x = event.clientX; cursor.y = event.clientY; cursor.near = true; schedule(); }, { passive: true } ); window.addEventListener("pointerleave", function () { cursor.near = false; schedule(); }); } } /** -1..1 across a box grown by `reach` times its own size. */ function offset(box, point, axis, reach) { var mid = axis === "x" ? box.left + box.width / 2 : box.top + box.height / 2; var span = (axis === "x" ? box.width : box.height) * (reach || 1); if (!span) return 0; return Math.max(-1, Math.min(1, (point - mid) / span)); } // ------------------------------------------------------------------------- // The mark, built out into an object // // The markup ships the flat SVG. This puts a preserve-3d stage beside it and // only then adds `on`, which is what hides the picture — so the mark is the // mark with scripting off, and this file failing to load costs nothing. // // Geometry is the same 64-unit grid the SVG uses, expressed as fractions of // --s so one element scales to any size: bar height 10.9%, longest bar 50%, // corner radius 23.4%, the three bars at 26.6%, 44.5% and 62.5% down. // ------------------------------------------------------------------------- var BARS = [ { y: 0.265625, w: 0.203125 }, { y: 0.4453125, w: 0.3515625 }, { y: 0.625, w: 0.5 }, ]; /** Slices through the slab. Enough that the rim reads as milled, not stepped. */ var SLICES = 14; function buildMark(host) { var size = parseFloat(host.getAttribute("data-mark3d")) || 24; host.style.setProperty("--s", size + "px"); var depth = size * 0.24; var stage = document.createElement("span"); stage.className = "m3d-stage"; var slab = document.createElement("span"); slab.className = "m3d-slab"; for (var i = 0; i < SLICES; i++) { var slice = document.createElement("i"); slice.style.setProperty("--z", (i * depth) / SLICES); slab.appendChild(slice); } stage.appendChild(slab); BARS.forEach(function (spec, index) { var bar = document.createElement("span"); bar.className = "m3d-bar"; bar.style.setProperty("--y", spec.y); bar.style.setProperty("--w", spec.w); bar.style.setProperty("--bi", index); var grow = document.createElement("span"); for (var j = 0; j < 4; j++) { var slice = document.createElement("i"); slice.style.setProperty("--z", j * size * 0.009); grow.appendChild(slice); } bar.appendChild(grow); stage.appendChild(bar); }); var glass = document.createElement("span"); glass.className = "m3d-glass"; stage.appendChild(glass); host.appendChild(stage); host.classList.add("on"); // The field is the mark's own region grown by four times its size, so the // cursor is answered well before it arrives rather than only on top of the // 24px target itself. follows(host, function (box, point) { if (!box.width) return; var dx = point.near ? offset(box, point.x, "x", 4) : 0; var dy = point.near ? offset(box, point.y, "y", 4) : 0; stage.style.setProperty("--ry", -23 + dx * 14 + "deg"); stage.style.setProperty("--rx", 12 - dy * 14 + "deg"); glass.style.setProperty("--gx", 34 - dx * 30 + "%"); glass.style.setProperty("--gy", 24 - dy * 22 + "%"); }); once(host, function (target) { target.classList.add("in"); }); } $$("[data-mark3d]").forEach(buildMark); // ------------------------------------------------------------------------- // The headline, a word at a time // // Each word gets a box that clips it and an inner span that rises out of the // box. Element children — the accented span in the hero headline — are // wrapped whole rather than taken apart, so the accent survives and any // markup inside it comes along. // ------------------------------------------------------------------------- $$("[data-words]").forEach(function (line) { var pieces = []; Array.prototype.slice.call(line.childNodes).forEach(function (node) { if (node.nodeType === 3) { node.textContent.split(/(\s+)/).forEach(function (chunk) { if (!chunk.trim()) { if (chunk) pieces.push(document.createTextNode(chunk)); return; } var inner = document.createElement("span"); inner.textContent = chunk; pieces.push(inner); }); } else if (node.nodeType === 1) { pieces.push(node); } }); if (!pieces.length) return; line.textContent = ""; var i = 0; pieces.forEach(function (piece) { if (piece.nodeType === 3) { line.appendChild(piece); return; } var box = document.createElement("span"); box.className = "w"; box.style.setProperty("--wi", i++); box.appendChild(piece); line.appendChild(box); }); line.classList.add("split"); once(line, function (target) { target.classList.add("in"); }, "0px"); }); // ------------------------------------------------------------------------- // Pointer light on a card, and the pull on the primary action // // Both write a custom property and stop. No layout is read on move except // the one rect, and neither runs at all for anyone who asked for calm. // ------------------------------------------------------------------------- $$("[data-spot]").forEach(function (card) { /* A card that leans towards the cursor. Two degrees is the whole range: enough that it reads as an object being looked at, not enough for a paragraph on it to start keystoning. The axis is perpendicular to the direction of the cursor, which is what makes it lean *towards* it rather than about one edge — and it goes on `rotate`, not `transform`, because the reveal owns `transform` on these elements. */ var leans = card.hasAttribute("data-lean"); follows(card, function (box, point) { card.style.setProperty("--mx", point.x - box.left + "px"); card.style.setProperty("--my", point.y - box.top + "px"); if (!leans || !box.width) return; var over = point.near && point.x >= box.left && point.x <= box.right && point.y >= box.top && point.y <= box.bottom; if (!over) { card.style.rotate = ""; card.style.translate = ""; return; } var dx = (point.x - (box.left + box.width / 2)) / (box.width / 2); var dy = (point.y - (box.top + box.height / 2)) / (box.height / 2); var reach = Math.min(1, Math.sqrt(dx * dx + dy * dy)); // Dead centre there is no direction to lean in, and an axis of zero // length is not a rotation the browser can make sense of. card.style.rotate = reach < 0.02 ? "none" : -dy + " " + dx + " 0 " + (reach * 2).toFixed(2) + "deg"; card.style.translate = "0 -2px"; }); }); /* A card that leans very slightly towards the cursor. Two and a half degrees is the whole range: enough that the card reads as an object being looked at, not so much that a paragraph on it starts to keystone. */ $$("[data-tilt]").forEach(function (host) { var stage = host.firstElementChild; if (!stage) return; var lean = Number(host.getAttribute("data-tilt")) || 6; follows(host, function (box, point) { if (!box.width) return; var dx = point.near ? offset(box, point.x, "x", 1.6) : 0; var dy = point.near ? offset(box, point.y, "y", 1.6) : 0; stage.style.setProperty("--ry", -9 + dx * lean + "deg"); stage.style.setProperty("--rx", 5 - dy * lean + "deg"); }); }); $$("[data-magnet]").forEach(function (button) { var pull = Number(button.getAttribute("data-magnet")) || 5; follows(button, function (box, point) { if (!box.width) return; var dx = (point.x - (box.left + box.width / 2)) / box.width; var dy = (point.y - (box.top + box.height / 2)) / box.height; // Outside its own reach it sits still. A button that leans towards a // cursor on the other side of the page is a button that is never still, // which is the opposite of the point. var near = point.near && Math.abs(dx) < 1.1 && Math.abs(dy) < 1.6; button.style.setProperty("--mgx", near ? dx * pull + "px" : "0px"); button.style.setProperty("--mgy", near ? dy * pull * 0.6 + "px" : "0px"); }); }); // ------------------------------------------------------------------------- // Drawings that keep going // // The four drawings in the bento used to play once on entry and hold, which // made them illustrations of a thing rather than the thing happening. They // loop now — a line filed and a highlight pulled out of it, a document // rebuilt for a different posting, a card walking the board, a follow-up // coming due. // // This is a deliberate exception to "nothing loops", and it is bounded three // ways. Every cycle begins and ends at the drawing's resting state, so a // paused one is indistinguishable from a still one. Nothing runs unless it is // on screen — the class goes on when the drawing enters and comes off when it // leaves, so four animations are not burning a phone's battery while somebody // reads the FAQ five sections down. And `calm` never adds the class at all, // which leaves the finished picture exactly as it was. // ------------------------------------------------------------------------- if (!calm && "IntersectionObserver" in window) { var living = new IntersectionObserver( function (entries) { entries.forEach(function (entry) { entry.target.classList.toggle("live", entry.isIntersecting); }); }, { threshold: 0.2 } ); $$("[data-live]").forEach(function (el) { living.observe(el); }); } // ------------------------------------------------------------------------- // Reveals // ------------------------------------------------------------------------- $$("[data-reveal]").forEach(function (el) { once(el, function (target) { target.classList.add("in"); }); }); // ------------------------------------------------------------------------- // Nav and scroll progress // ------------------------------------------------------------------------- var nav = $(".nav"); var progress = $(".progress"); var ticking = false; /* The stylesheet drives the progress bar off a scroll timeline where the browser supports one, which is both smoother and off the main thread. Only fill it from here when it doesn't. */ var cssDrivesProgress = window.CSS && CSS.supports && CSS.supports("animation-timeline: scroll()"); if (cssDrivesProgress) progress = null; /* Nav links that point at a section on this page, paired with their target, so scrolling can say which one you are in. Anything pointing elsewhere — GitHub, the app — is left alone. */ var marks = $$(".nav-links a.link[href^='#']") .map(function (link) { var section = document.getElementById(link.getAttribute("href").slice(1)); return section ? { link: link, section: section } : null; }) .filter(Boolean); var marked = null; function markSection() { if (!marks.length) return; /* The section you are "in" is the last one whose top has passed a line a third of the way down the viewport — steadier than asking which is most visible, because these sections differ wildly in height. */ var line = window.innerHeight / 3; var current = null; marks.forEach(function (m) { if (m.section.getBoundingClientRect().top <= line) current = m; }); if (current === marked) return; if (marked) marked.link.removeAttribute("aria-current"); if (current) current.link.setAttribute("aria-current", "true"); marked = current; } function onScroll() { if (ticking) return; ticking = true; requestAnimationFrame(function () { ticking = false; var y = window.scrollY || window.pageYOffset; if (nav) nav.classList.toggle("stuck", y > 12); if (progress) { var height = document.documentElement.scrollHeight - window.innerHeight; progress.style.setProperty("--p", height > 0 ? Math.min(1, y / height) : 0); } markSection(); }); } window.addEventListener("scroll", onScroll, { passive: true }); onScroll(); // ------------------------------------------------------------------------- // Smoothed scrolling // // Every wheel notch and every key press moves a target; the page eases // towards it. Trackpads included — an earlier pass left them alone on the // theory that a precision device is already smooth, and the result was a page // that behaved differently depending on what you happened to be holding. // One behaviour, tuned once. // // Deliberately NOT the usual implementation of this, which translates a // wrapper element and leaves the document's real scroll offset at zero. Four // things here read that offset — the sticky tour chapters, the progress bar // and two scroll timelines — and a transformed wrapper breaks all of them. So // the scroll is real: window.scrollTo, once a frame, towards a target. // Find-on-page, the back button and the scrollbar all go on working. // // Two things are left alone, on purpose: touch, where the platform's own // momentum is better than anything written here, and a pane with its own // scrollbar, which should scroll itself. // ------------------------------------------------------------------------- if (!calm && window.matchMedia("(pointer: fine)").matches) { (function () { var target = 0; var frame = 0; var last = 0; var gliding = false; var root = document.documentElement; /* How much of the remaining distance is closed every 16.7ms. Lower is longer and glassier; higher is tighter and more like the browser. This is the one number worth arguing with. */ var EASE = 0.115; function limit() { return Math.max(0, root.scrollHeight - window.innerHeight); } function step(now) { var gap = Math.min(64, now - last); last = now; var current = window.scrollY; var left = target - current; if (Math.abs(left) < 0.4) { window.scrollTo(0, target); stop(); return; } // Frame-rate independent, or 120Hz arrives twice as fast as 60. window.scrollTo(0, current + left * (1 - Math.pow(1 - EASE, gap / 16.67))); frame = requestAnimationFrame(step); } function stop() { frame = 0; gliding = false; // Hand the anchors back their smooth scrolling. root.style.scrollBehavior = ""; } function begin() { if (gliding) return; target = window.scrollY; gliding = true; last = performance.now(); // A CSS smooth scroll on every frame of our own would fight this. root.style.scrollBehavior = "auto"; } function push(amount) { begin(); target = Math.max(0, Math.min(limit(), target + amount)); if (!frame) frame = requestAnimationFrame(step); } function goto(where) { begin(); target = Math.max(0, Math.min(limit(), where)); if (!frame) frame = requestAnimationFrame(step); } /* Anything that moves the page other than this — a scrollbar drag, an anchor, the browser restoring a position — becomes the new target, or the next notch would yank it back to where the glide was heading. */ function resync() { if (!gliding) target = window.scrollY; } window.addEventListener("scroll", resync, { passive: true }); window.addEventListener("resize", resync, { passive: true }); function ownScroller(node) { while (node && node.nodeType === 1 && node !== document.body) { var style = window.getComputedStyle(node); if (/(auto|scroll)/.test(style.overflowY) && node.scrollHeight > node.clientHeight + 1) { return true; } node = node.parentNode; } return false; } window.addEventListener( "wheel", function (event) { if (event.ctrlKey || event.defaultPrevented) return; if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) return; if (ownScroller(event.target)) return; var amount = event.deltaY * (event.deltaMode === 1 ? 40 : event.deltaMode === 2 ? window.innerHeight : 1); if (!amount) return; event.preventDefault(); push(amount); }, { passive: false } ); /* The keys that scroll, so a page turned with the keyboard arrives the same way one turned with the wheel does. Anything with its own idea of what these keys mean — a field, a button, a — keeps it. */ var TYPING = /^(input|textarea|select)$/; window.addEventListener("keydown", function (event) { if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return; var on = event.target; if (on && (TYPING.test((on.tagName || "").toLowerCase()) || on.isContentEditable)) return; if (on && on.closest && on.closest("button, summary, a, [tabindex], [role='button']")) return; if (ownScroller(on)) return; var page = window.innerHeight * 0.88; var line = 90; var by = null; var to = null; if (event.key === "PageDown") by = page; else if (event.key === "PageUp") by = -page; else if (event.key === " " || event.key === "Spacebar") by = event.shiftKey ? -page : page; else if (event.key === "ArrowDown") by = line; else if (event.key === "ArrowUp") by = -line; else if (event.key === "Home") to = 0; else if (event.key === "End") to = limit(); else return; event.preventDefault(); if (to !== null) goto(to); else push(by); }); })(); } // ------------------------------------------------------------------------- // The transcript // // The text is already on the page; this retypes it. Each turn waits for the // one before it, tool chips appear while the answer is still being written, // and each chip goes green when its call "returns" — which is what actually // happens in a client, and the reason the sequence is worth showing at all. // ------------------------------------------------------------------------- function typeInto(el, done) { var full = el.getAttribute("data-text") || el.textContent; el.setAttribute("data-text", full); var speed = Number(el.getAttribute("data-speed")) || 16; el.textContent = ""; var caret = document.createElement("span"); caret.className = "caret"; el.appendChild(caret); var i = 0; (function step() { // Type in small bites rather than one character per frame: a per-frame // character is slower than anyone reads and makes long lines drag. var bite = full.charAt(i) === " " ? 2 : 1; i = Math.min(full.length, i + bite); caret.insertAdjacentText("beforebegin", full.slice(i - bite, i)); if (i < full.length) { setTimeout(step, speed); } else { caret.remove(); if (done) done(); } })(); } function playTape(tape) { var steps = $$("[data-step]", tape).sort(function (a, b) { return Number(a.getAttribute("data-step")) - Number(b.getAttribute("data-step")); }); function next(index) { if (index >= steps.length) { tape.classList.add("played"); return; } var step = steps[index]; var body = $("[data-type]", step) || step; var calls = $$(".call", step); /* A step can spend a moment working before it says anything, which is what a client connected over MCP actually does: the calls go out, they come back, and only then is there an answer to write. `data-think` is how long that takes; the element it shows ships hidden, so a page without this file has the answer and no spinner. */ var think = Number(step.getAttribute("data-think")) || 0; var thinking = $("[data-thinking]", step); // Chips land while it is still working, and go green as each returns. calls.forEach(function (call, i) { setTimeout(function () { call.classList.add("in"); setTimeout(function () { call.classList.add("done"); }, 420 + i * 160); }, 260 + i * 150); }); step.classList.add("in"); function speak() { if (thinking) thinking.hidden = true; typeInto(body, function () { setTimeout(function () { next(index + 1); }, Number(step.getAttribute("data-pause")) || 480); }); } if (think && thinking) { thinking.hidden = false; setTimeout(speak, think); } else { speak(); } } next(0); } $$("[data-tape]").forEach(function (tape) { if (calm) { $$(".call", tape).forEach(function (c) { c.classList.add("in", "done"); }); $$("[data-step]", tape).forEach(function (s) { s.classList.add("in"); }); $$("[data-thinking]", tape).forEach(function (t) { t.hidden = true; }); return; } /* Hide the answer text until its turn comes, without hiding the layout it occupies — the panel must not change height while it plays, or every section below it walks down the page a line at a time while somebody is reading. Measure the finished line, hold that height, then empty it. Measured again once the webfont has landed, because Inter is wider than the fallback and a height reserved against the wrong face is the wrong height. Only for a tape that has not started: re-measuring one mid-type would throw away what it had written. */ var lines = $$("[data-type]", tape); function reserve() { lines.forEach(function (el) { var full = el.getAttribute("data-text"); var showing = el.textContent; el.style.minHeight = ""; el.textContent = full; el.style.minHeight = el.getBoundingClientRect().height + "px"; el.textContent = showing; }); } lines.forEach(function (el) { el.setAttribute("data-text", el.textContent); }); reserve(); lines.forEach(function (el) { el.textContent = ""; }); var started = false; if (document.fonts && document.fonts.ready) { document.fonts.ready.then(function () { if (!started) reserve(); }); } once(tape, function (target) { started = true; playTape(target); }, "0px 0px -20% 0px"); }); // ------------------------------------------------------------------------- // Product shots, drawn at a real app width and scaled to fit // // Reflowing a screenshot into a narrow column stops it being a screenshot of // anything — the type goes big relative to the chrome and the columns you // are meant to see disappear. So each mock is laid out at the width the app // is actually used at and then scaled down as one object, which is what a // photograph of the screen would have done. // ------------------------------------------------------------------------- var fitted = $$("[data-fit]"); // Below this, the app's own 12.5px rows stop being legible and the shot // stops being worth showing. Past it we hold the scale and let the frame // scroll sideways instead, which is what a phone can honestly do with a // picture of a 1440px screen. var FLOOR = 0.72; function fit() { fitted.forEach(function (el) { var design = Number(el.getAttribute("data-fit")) || 1200; var host = el.parentElement; if (!host) return; el.style.width = design + "px"; var scale = Math.max(FLOOR, Math.min(1, host.clientWidth / design)); el.style.transform = scale < 1 ? "scale(" + scale + ")" : ""; // offsetHeight ignores the transform, which is exactly what we need to // work out how tall the scaled result is. host.style.height = Math.round(el.offsetHeight * scale) + "px"; host.classList.toggle("pannable", design * scale > host.clientWidth + 1); }); } if (fitted.length) { fit(); // Fonts land after first paint and change every height in here. if (document.fonts && document.fonts.ready) document.fonts.ready.then(fit); window.addEventListener("load", fit); var refit; var again = function () { clearTimeout(refit); refit = setTimeout(fit, 100); }; window.addEventListener("resize", again); // The board gets shorter when a card moves stage, and an image dropped into // a slot changes everything. Watch the drawings rather than guess when. if (window.ResizeObserver) { var ro = new ResizeObserver(again); fitted.forEach(function (el) { ro.observe(el); }); } } // ------------------------------------------------------------------------- // Monthly or annual // // Both figures are in the markup and the annual one ships hidden, so the page // without this file is the monthly price and the control is not there to // press. All this does is swap which of the two pairs is showing. // ------------------------------------------------------------------------- $$("[data-plans]").forEach(function (group) { var buttons = $$("button[data-period]", group); var swappable = $$("[data-when]"); if (!buttons.length || !swappable.length) return; group.hidden = false; function show(period) { buttons.forEach(function (button) { var on = button.getAttribute("data-period") === period; button.classList.toggle("on", on); button.setAttribute("aria-pressed", on ? "true" : "false"); }); swappable.forEach(function (el) { el.hidden = el.getAttribute("data-when") !== period; }); } buttons.forEach(function (button) { button.addEventListener("click", function () { show(button.getAttribute("data-period")); }); }); }); // ------------------------------------------------------------------------- // Screenshot and video slots // // Every slot on this page already renders something real. This looks for a // file named after the slot and, only if it loads, puts it on top. So the // page is complete with an empty media/ folder, and adding art is dropping a // file in — no markup to edit. site/media/README.md has the list. // ------------------------------------------------------------------------- $$("[data-shot]").forEach(function (slot) { var name = slot.getAttribute("data-shot"); var kind = slot.getAttribute("data-media") || "image"; if (kind === "video") { var video = document.createElement("video"); video.muted = true; video.loop = true; video.playsInline = true; video.autoplay = !calm; video.controls = calm; video.preload = "metadata"; video.src = "media/" + name + ".mp4"; video.addEventListener("loadeddata", function () { slot.replaceChildren(video); }); return; } var image = new Image(); image.decoding = "async"; image.loading = "lazy"; image.alt = slot.getAttribute("data-alt") || ""; image.addEventListener("load", function () { if (image.naturalWidth < 2) return; image.srcset = "media/" + name + ".png 1x, media/" + name + "@2x.png 2x"; slot.replaceChildren(image); }); image.src = "media/" + name + ".png"; }); // Add ?slots to the URL to see where art goes and what each file is called. if (/[?&]slots\b/.test(location.search)) document.body.classList.add("show-slots"); // ------------------------------------------------------------------------- // The one live number on the page, in the footer beside the GitHub link. It // ships hidden and only appears if the count actually arrives, so a rate // limit or an offline visitor sees a plain link rather than an empty chip. // ------------------------------------------------------------------------- var stars = $("#stars"); if (stars) { fetch("https://api.github.com/repos/shifulaboratories/Hired") .then(function (r) { return r.ok ? r.json() : null; }) .then(function (repo) { if (!repo || typeof repo.stargazers_count !== "number") return; stars.textContent = repo.stargazers_count >= 1000 ? (repo.stargazers_count / 1000).toFixed(1) + "k" : String(repo.stargazers_count); stars.hidden = false; }) .catch(function () {}); } })();