Movies
Nothing about a movie note promises much at the start: a title, the date I sat through it, a column of scores still blank. Then the card gets hold of the file and it opens like something off a streaming service: poster, cast, a trailer, the whole thing washed in the film’s own colors.
It does that last part itself. It reads the poster, finds the one color running through it, and repaints its whole face to match. A noir comes up bruised and blue, a comedy comes up warm, and I never picked a single shade. Watching it recolor for a film it has never seen is the closest the vault comes to a card that knows what it is looking at.
The cast and the trailers aren’t kept anywhere. Every time the note opens, the card reaches out to the same film database the rest of the internet runs on and pulls them back fresh: faces, names, a clip or two. Close the note and they’re gone again. It rebuilds the film from scratch each time you look.
Making one is half a minute of work. I type a title, the vault asks the database which film I mean, and it writes the rest: year, runtime, director, the poster. Then it files the note under its real name. The only thing I add by hand is the part no database has, which is whether it was any good.
Underneath, it’s nothing clever. A rule that catches the note, a template that lays out the card, a script that fills the blanks. You’re not meant to see any of it. You open a note about a movie, and a movie looks back.
Custom Views Code
<div class="movie-card" data-tmdb-id="{{tmdbId}}" data-name="{{file.name}}">
<!-- ── Hero: image + gradient overlay ─────────────────────── -->
<div class="hero">
<img
id="bgImg"
class="hero-img"
src="{{backdrop|first}}"
crossorigin="anonymous"
alt=""
/>
<div class="hero-gradient" id="gradientOverlay"></div>
</div>
<!-- ── Content area (overlaps hero) ──────────────────────── -->
<div class="content-area">
<!-- Row 1: movie info (left) + starring/director (right) -->
<div class="info-row">
<div class="movie-info">
<p class="movie-title">{{file.basename}}</p>
<p class="movie-genre">{{genre|split:","|join:"<span style='color:var(--text-faint)'> · </span>"}}</p>
<p class="movie-desc">{{description}}</p>
<div class="media-score" data-kind="movie"></div>
<div class="meta-row">
<p class="movie-meta">
{{year}} · {% if (runtime / 60).floor() != 0 %}{{(runtime / 60).round(0)}} hr
{% endif %}{{runtime % 60}} min
</p>
</div>
</div>
<div class="starring-col">
<div class="starring-row">
<p class="starring-label">Starring</p>
<p class="starring-value">{{cast|slice:0,4|split:","|join:", "}}</p>
</div>
<div class="starring-row">
<p class="starring-label">Director</p>
<p class="starring-value">
{{director|split:","|join:", "}}
</p>
</div>
</div>
</div>
<!-- Row 2: content/watch + trailers + cast sections -->
<div class="bottom-section">
<!-- file.content + Watch Now / 4K -->
<div class="content-watch-row">
<div class="file-content-wrap">
<button class="movie-more" type="button">SHOW MORE</button>
<div class="movie-notes"></div>
</div>
<div class="watch-col">
<div class="watch-row">
<p class="watch-label">Watch Now</p>
<div class="badge-4k">
<div class="badge-4k-bg"></div>
<span class="badge-4k-text">4K</span>
</div>
</div>
</div>
</div>
<!-- Trailers -->
<div class="trailers-section">
<p class="section-heading">Trailers</p>
<div class="trailers-row" id="trailersRow">
<span class="tmdb-loading">Loading trailers…</span>
</div>
</div>
<!-- Cast and Crew -->
<div class="cast-section">
<p class="section-heading">Cast and Crew</p>
<div class="cast-row" id="castRow">
<div class="cast-inner">
<span class="tmdb-loading">Loading cast…</span>
</div>
</div>
</div>
</div>
</div>
</div>
/* ============================================================
Harker — shared Movies/Shows card CSS
Used by the Custom Views "Movies" (harker-movies) and "Shows"
(harker-shows) views. Both render .movie-card; this is loaded
once here instead of duplicated in each view's css field.
(Games/Books/Albums have their own gv-/bk-/alb- styles.)
============================================================ */
.obsidian-custom-view-editable .obsidian-custom-view-render, .obsidian-custom-view-render {
padding: 0 !important;
--line-width: 100%;
--max-width: none;
}
.file-content-wrap .markdown-rendered-content.markdown-preview-view.markdown-rendered {
margin: 0;
padding: 0;
}
blockquote {
color: var(--blockquote-color);
font-style: var(--blockquote-font-style);
background-color: var(--blockquote-background-color);
border-inline-start: var(--blockquote-border-thickness) solid var(--blockquote-border-color);
padding-top: 0;
padding-bottom: 0;
padding-inline-start: var(--size-4-6);
margin-inline-start: 0;
margin-inline-end: 0;
}
.cm-content.cm-lineWrapping {
padding-bottom: 40px !important;
}
/* ================================================================
Movie Card — Obsidian Custom View
Faithful translation of Figma node 1:2
================================================================ */
.obsidian-custom-view-render, .obsidian-custom-view-editable .obsidian-custom-view-render {
padding: 0;
}
.movie-card {
background-color: var(--color-bg, #7baaba);
position: relative;
width: 100%;
padding-bottom: 145px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
caret-color: pink;
}
/* ── Hero ──────────────────────────────────────────────────────── */
.hero {
position: absolute;
width: 100%;
flex-shrink: 0;
line-height: 0;
}
.hero-img {
display: block;
width: 100%;
aspect-ratio: 3840 / 2160;
object-fit: cover;
max-width: none;
}
/* Gradient overlay.
Figma: div starting at mt-372px, h-600px, inside 972px hero.
Stop 1 (transparent): 372 + 600×23.5% = 513px from hero top = 52.77% of 972
Stop 2 (solid): 372 + 600×53.08% = 690.5px = 71.04% of 972
Applied as inset-0 so % are of the image height at any width. */
.hero-gradient {
position: absolute;
inset: 0;
background: linear-gradient(to bottom,
transparent 52.77%,
var(--color-bg, #7baaba) 71.04%
);
pointer-events: none;
}
/* ── Content area ──────────────────────────────────────────────── */
/* Figma: absolute top-583px over a 972px hero.
HTML: negative margin = 583px - (image height as % of width).
Image aspect 3840/2160 → height = width × 56.25%.
So margin-top: calc(583px - 56.25%) replicates the overlap at any width. */
.content-area {
margin-top: calc(100% * (6/16));
position: relative;
z-index: 1;
width: 100%;
box-sizing: border-box;
padding: 0 100px 10px;
display: flex;
flex-direction: column;
gap: 20px;
align-items: flex-start;
}
/* ── Info row ──────────────────────────────────────────────────── */
.info-row {
display: flex;
flex-wrap: wrap;
row-gap: 20px;
column-gap: 30px;
align-items: flex-end;
justify-content: space-between;
flex-shrink: 0;
width: 100%;
}
/* Left: title / genre / description / meta */
.movie-info {
display: flex;
flex: 1 0 0;
flex-direction: column;
gap: 12px;
align-items: flex-start;
max-width: 900px;
min-width: 800px;
overflow: clip;
}
.movie-title {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro', sans-serif;
font-size: calc(36px + 1.5vw);
font-weight: 500;
line-height: normal;
color: var(--text-normal);
letter-spacing: -1.28px;
width: 100%;
margin: 0;
word-break: break-word;
}
.movie-genre {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', sans-serif;
font-size: 24px;
font-weight: 510;
line-height: normal;
color: var(--text-muted);
letter-spacing: -0.48px;
width: 100%;
margin: 0;
word-break: break-word;
}
.movie-genre a {
text-decoration: none;
color: var(--text-muted);
}
.movie-genre a:hover {
text-decoration: none;
color: var(--color-base-80);
}
.movie-desc {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', sans-serif;
font-size: 18px;
font-weight: 400;
line-height: 1.6;
color: var(--text-muted);
letter-spacing: -0.36px;
width: 100%;
margin: 0;
word-break: break-word;
}
.meta-row {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 100%;
}
.movie-meta {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', sans-serif;
font-size: 18px;
font-weight: 400;
line-height: normal;
color: var(--text-faint);
letter-spacing: -0.36px;
flex: 1 0 0;
min-width: 1px;
margin: 0;
word-break: break-word;
}
/* Right: Starring / Director */
.starring-col {
display: flex;
flex: 1 0 0;
flex-direction: column;
align-items: flex-start;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', sans-serif;
font-size: 16px;
font-weight: 400;
line-height: 1.6;
letter-spacing: -0.32px;
max-width: 400px;
min-width: 400px;
overflow: clip;
padding-top: 29px;
}
.starring-row {
display: flex;
gap: 10px;
align-items: flex-start;
flex-shrink: 0;
width: 100%;
}
.starring-label {
color: var(--text-faint);
white-space: nowrap;
flex-shrink: 0;
margin: 0;
}
.starring-value {
flex: 1 0 0;
min-width: 1px;
color: var(--text-muted);
margin: 0;
}
.starring-value a {
text-decoration: none;
color: var(--text-normal);
}
/* ── Bottom section ────────────────────────────────────────────── */
.bottom-section {
display: flex;
flex-direction: column;
gap: 20px;
align-items: flex-start;
padding-top: 30px;
flex-shrink: 0;
width: 100%;
}
/* Content + Watch Now row */
.content-watch-row {
display: flex;
gap: 30px;
align-items: flex-start;
flex-shrink: 0;
width: 100%;
justify-content: space-between;
}
.file-content-wrap {
flex-shrink: 0;
flex-grow: 1;
width: auto;
max-width: 700px;
}
.file-content-text {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', sans-serif;
font-size: 18px;
font-weight: 400;
line-height: 1.6;
color: var(--text-muted);
letter-spacing: -0.36px;
flex: 1 0 0;
min-width: 1px;
margin: 0;
word-break: break-word;
}
.watch-col {
display: flex;
flex: 1 0 0;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 1px;
max-width: 400px;
}
.watch-row {
display: flex;
gap: 10px;
align-items: center;
flex-shrink: 0;
width: 100%;
}
.watch-label {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', sans-serif;
font-size: 22px;
font-weight: 400;
line-height: 1.6;
color: var(--text-normal);
letter-spacing: -0.44px;
flex-shrink: 0;
white-space: nowrap;
margin: 0;
}
/* 4K badge: CSS grid stack (bg rect + text overlapping) */
.badge-4k {
display: inline-grid;
grid-template-columns: max-content;
grid-template-rows: max-content;
line-height: 0;
place-items: start;
flex-shrink: 0;
}
.badge-4k-bg {
grid-column: 1;
grid-row: 1;
background-color: var(--color-base-30);
height: 27px;
width: 44px;
border-radius: 7px;
}
.badge-4k-text {
grid-column: 1;
grid-row: 1;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', sans-serif;
font-size: 14px;
font-weight: 510;
line-height: 1.6;
color: var(--text-muted);
letter-spacing: -0.28px;
white-space: nowrap;
margin-left: 13px;
margin-top: 3px;
}
/* ── Trailers ──────────────────────────────────────────────────── */
.trailers-section {
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
flex-shrink: 0;
width: 100%;
}
.section-heading {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', sans-serif;
font-size: 24px;
font-weight: 400;
line-height: 1.6;
color: var(--text-normal);
letter-spacing: -0.48px;
white-space: nowrap;
flex-shrink: 0;
margin: 0;
}
.trailers-row {
display: flex;
gap: 20px;
width: 100%;
overflow: scroll;
align-items: flex-start;
flex-shrink: 0;
}
.trailer-card {
position: relative;
height: 159px;
width: 282px;
border-radius: 8px;
flex-shrink: 0;
overflow: hidden;
cursor: pointer;
}
.trailer-thumb {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
max-width: none;
}
.trailer-dark-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.2);
pointer-events: none;
}
/* ── Cast and Crew ─────────────────────────────────────────────── */
.cast-section {
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
flex-shrink: 0;
width: 100%;
}
.cast-row {
display: flex;
align-items: flex-start;
flex-shrink: 0;
width: 100%;
overflow: scroll;
}
.cast-inner {
display: flex;
flex: 1 0 0;
gap: 20px;
align-items: flex-start;
min-width: 1px;
}
.cast-card {
display: flex;
flex-direction: column;
gap: 16px;
align-items: flex-start;
flex-shrink: 0;
}
.cast-portrait {
height: 179px;
width: 120px;
border-radius: 8px;
object-fit: cover;
display: block;
flex-shrink: 0;
}
.cast-avatar {
height: 179px;
width: 120px;
border-radius: 8px;
background-color: var(--color-base-20, #2a2a2a);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-faint);
font-size: 36px;
font-weight: 300;
flex-shrink: 0;
}
.cast-info {
display: flex;
flex-direction: column;
align-items: flex-start;
line-height: 1.6;
flex-shrink: 0;
width: 120px;
word-break: break-word;
}
.cast-name {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', sans-serif;
font-size: 16px;
font-weight: 400;
letter-spacing: -0.32px;
color: var(--text-normal);
width: 100%;
margin: 0;
}
.cast-role {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', sans-serif;
font-size: 14px;
font-weight: 400;
font-style: italic;
letter-spacing: -0.28px;
color: var(--text-muted);
width: 100%;
margin: 0;
}
.tmdb-loading {
font-family: -apple-system, sans-serif;
font-size: 14px;
color: var(--text-faint);
}
/* ===== RESPONSIVE (added 2026-05-29) — narrow panes / mobile =====
Gated >=1000px so desktop is unchanged; container queries + @media fallback. */
.movie-card { container-type: inline-size; container-name: mv; }
@container mv (max-width: 1000px) {
.content-area { padding-left: clamp(18px, 5cqw, 100px); padding-right: clamp(18px, 5cqw, 100px); }
.info-row { flex-direction: column; align-items: stretch; flex-wrap: nowrap; }
.movie-info { min-width: 0; max-width: none; }
.starring-col { min-width: 0; max-width: none; padding-top: 0; }
.content-watch-row { flex-direction: column; gap: 20px; }
.watch-col { max-width: none; align-items: flex-start; }
.file-content-wrap { max-width: none; }
}
@container mv (max-width: 560px) {
.content-area { padding-left: 18px; padding-right: 18px; }
.movie-title { font-size: clamp(28px, 9cqw, 40px); }
}
@media (max-width: 1000px) {
.content-area { padding-left: clamp(18px, 5vw, 100px); padding-right: clamp(18px, 5vw, 100px); }
.info-row { flex-direction: column; align-items: stretch; flex-wrap: nowrap; }
.movie-info { min-width: 0; max-width: none; }
.starring-col { min-width: 0; max-width: none; padding-top: 0; }
.content-watch-row { flex-direction: column; gap: 20px; }
.watch-col { max-width: none; align-items: flex-start; }
.file-content-wrap { max-width: none; }
}
@media (max-width: 560px) {
.content-area { padding-left: 18px; padding-right: 18px; }
.movie-title { font-size: clamp(28px, 9vw, 40px); }
}
/* ── SHOW MORE button + collapsible notes (added 2026-05-30) ──────
Mirrors the Albums card: body is hidden until the pill is pressed. */
.movie-more {
display: inline-block;
align-self: flex-start;
font: 600 12px/1 -apple-system, BlinkMacSystemFont, sans-serif;
letter-spacing: .12em;
color: var(--text-normal);
background: var(--background-modifier-hover);
border: 1px solid var(--background-modifier-border);
padding: 10px 18px;
border-radius: 999px;
cursor: pointer;
transition: background .15s ease;
}
.movie-more:hover { background: var(--background-modifier-active-hover); }
.movie-notes {
display: none;
margin-top: 16px;
font-size: 18px;
line-height: 1.6;
color: var(--text-muted);
}
.movie-card.notes-open .movie-notes,
.video-card.notes-open .movie-notes { display: block; }
.movie-notes > :first-child { margin-top: 0; }
/* ── Computed 0–100 score + verdict (shared by movies/shows/books/games cards;
the Albums card has its own .alb-score). Added 2026-05-30. ────────── */
.media-score {
display: flex;
flex-direction: column;
gap: 3px;
margin: 4px 0;
}
.media-score-num {
font: 600 clamp(30px, 3vw, 42px)/1 -apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif;
letter-spacing: -1.2px;
color: var(--text-normal);
}
.media-score-num[data-empty] {
font-size: 17px;
font-weight: 500;
letter-spacing: 0;
color: var(--text-faint);
}
.media-verdict {
font-size: 14px;
line-height: 1.45;
color: var(--text-muted);
}
/* ============================================================
Videos card (harker-videos) — YouTube hero.
Reuses the Movies hero/info/notes classes above; this block
only adds the .video-card shell, the channel byline link and
the round "Watch on YouTube" play button. Added 2026-06-02.
============================================================ */
.video-card {
background-color: var(--color-bg, #7baaba);
position: relative;
width: 100%;
padding-bottom: 145px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
container-type: inline-size;
container-name: mv;
}
/* channel byline reuses .movie-genre sizing; make its link inherit */
.vid-channel a { text-decoration: none; color: var(--text-muted); }
.vid-channel a:hover { color: var(--color-base-80); text-decoration: none; }
/* lead paragraph (TL;DW) — slightly tighter than a full .movie-desc */
.vid-lead { max-width: 760px; }
/* meta row: left-aligned for videos (movies centre it) */
.video-card .meta-row { justify-content: flex-start; }
/* ── Watch on YouTube — round red play button + label ─────────── */
.vid-watch {
display: inline-flex;
align-items: center;
gap: 16px;
margin-top: 8px;
text-decoration: none;
cursor: pointer;
}
.vid-watch:hover { text-decoration: none; }
.vid-play {
position: relative;
width: 58px;
height: 58px;
border-radius: 999px;
background: #f5000f;
flex-shrink: 0;
box-shadow: 0 6px 24px rgba(245, 0, 15, 0.35);
transition: transform .15s ease, box-shadow .15s ease;
}
.vid-play::after {
content: "";
position: absolute;
top: 50%;
left: 54%;
transform: translate(-50%, -50%);
border-style: solid;
border-width: 11px 0 11px 18px;
border-color: transparent transparent transparent #fff;
}
.vid-watch:hover .vid-play {
transform: scale(1.06);
box-shadow: 0 8px 30px rgba(245, 0, 15, 0.5);
}
.vid-watch-label {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', sans-serif;
font-size: 20px;
font-weight: 600;
line-height: 1.15;
letter-spacing: -0.4px;
color: var(--text-normal);
}
@container mv (max-width: 1000px) {
.vid-lead { max-width: none; }
}
/* ================================================================
CONFIGURATION
================================================================ */
const _container = this;
const TMDB_API_KEY = (getComputedStyle(document.body).getPropertyValue("--tmdb-api-key")||"").trim().replace(/^["']|["']$/g,"");
const TMDB_ID = this.querySelector(".movie-card")?.dataset.tmdbId || "";
const TMDB_BASE = "https://api.themoviedb.org/3";
const TMDB_IMG = "https://image.tmdb.org/t/p";
/* ================================================================
COLOR MATH — sRGB / OKLCH / HSL
================================================================ */
function hexToRGB(hex) {
const h = hex.replace("#", "");
return [
parseInt(h.slice(0, 2), 16) / 255,
parseInt(h.slice(2, 4), 16) / 255,
parseInt(h.slice(4, 6), 16) / 255,
];
}
function rgbToHex(r, g, b) {
return (
"#" +
[r, g, b]
.map((v) =>
Math.round(Math.max(0, Math.min(255, v * 255)))
.toString(16)
.padStart(2, "0"),
)
.join("")
);
}
function rgbToHSL(r, g, b) {
const max = Math.max(r, g, b),
min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return [0, 0, l * 100];
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h;
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
default:
h = ((r - g) / d + 4) / 6;
}
return [h * 360, s * 100, l * 100];
}
function sRGBToLinear(v) {
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
}
function linearToSRGB(v) {
return v <= 0.0031308
? 12.92 * v
: 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
}
function relativeLuminance(hex) {
return hexToRGB(hex)
.map(sRGBToLinear)
.reduce((s, v, i) => s + v * [0.2126, 0.7152, 0.0722][i], 0);
}
function hexToOKLCH(hex) {
let [r, g, b] = hexToRGB(hex).map(sRGBToLinear);
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
const l_ = Math.cbrt(l),
m_ = Math.cbrt(m),
s_ = Math.cbrt(s);
const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_;
const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_;
const bk = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;
const C = Math.sqrt(a * a + bk * bk);
const H = ((Math.atan2(bk, a) * 180) / Math.PI + 360) % 360;
return [L, C, H];
}
function OKLCHToHex(L, C, H) {
const a = C * Math.cos((H * Math.PI) / 180);
const bk = C * Math.sin((H * Math.PI) / 180);
const l_ = L + 0.3963377774 * a + 0.2158037573 * bk;
const m_ = L - 0.1055613458 * a - 0.0638541728 * bk;
const s_ = L - 0.0894841775 * a - 1.291485548 * bk;
const lv = l_ * l_ * l_,
mv = m_ * m_ * m_,
sv = s_ * s_ * s_;
const r = linearToSRGB(
4.0767416621 * lv - 3.3077115913 * mv + 0.2309699292 * sv,
);
const g = linearToSRGB(
-1.2684380046 * lv + 2.6097574011 * mv - 0.3413193965 * sv,
);
const b = linearToSRGB(
-0.0041960863 * lv - 0.7034186147 * mv + 1.707614701 * sv,
);
return rgbToHex(r, g, b);
}
function multiplyColors(hex1, hex2) {
const [r1, g1, b1] = hexToRGB(hex1);
const [r2, g2, b2] = hexToRGB(hex2);
return rgbToHex(r1 * r2, g1 * g2, b1 * b2);
}
/* ================================================================
FLEXOKI SCALE BUILDER
================================================================ */
function buildScale(hex) {
const PAPER = "#FFFCF0";
const BLACK = "#100F0F";
const [L0, C0, H0] = hexToOKLCH(hex);
const [Lp] = hexToOKLCH(PAPER);
const [Lb] = hexToOKLCH(BLACK);
const L400 = Lp + (L0 - Lp) * 0.88;
const C400 = C0 * 0.82;
const L600 = L0 + (Lb - L0) * 0.18;
const C600 = C0 * 0.92;
const scale = { paper: PAPER, black: BLACK };
for (const step of [50, 100, 150, 200, 300, 400, 500, 600, 700, 800, 850, 875, 900, 950]) {
if (step < 400) {
const t = step / 400,
te = t * t * (3 - 2 * t);
scale[step] = multiplyColors(
OKLCHToHex(Lp + (L400 - Lp) * te, C400 * te, H0),
PAPER,
);
} else if (step === 400) {
scale[step] = multiplyColors(OKLCHToHex(L400, C400, H0), PAPER);
} else if (step === 500) {
scale[step] = hex;
} else if (step === 600) {
scale[step] = OKLCHToHex(L600, C600, H0);
} else {
const t = (step - 600) / 400,
te = t * t * (3 - 2 * t);
scale[step] = OKLCHToHex(
L600 + (Lb - L600) * te,
C600 * (1 - te),
H0,
);
}
}
return scale;
}
/* ================================================================
CANVAS COLOR SAMPLING
================================================================ */
function sampleDominantColor(img) {
try {
const canvas = document.createElement("canvas");
canvas.width = 200;
canvas.height = 20;
const ctx = canvas.getContext("2d");
ctx.drawImage(
img,
0,
img.naturalHeight * 0.88,
img.naturalWidth,
img.naturalHeight * 0.12,
0,
0,
200,
20,
);
const d = ctx.getImageData(0, 0, 200, 20).data;
let R = 0,
G = 0,
B = 0,
n = 0;
for (let i = 0; i < d.length; i += 4) {
R += d[i];
G += d[i + 1];
B += d[i + 2];
n++;
}
return (
"#" +
[Math.round(R / n), Math.round(G / n), Math.round(B / n)]
.map((v) => v.toString(16).padStart(2, "0"))
.join("")
);
} catch {
return null;
}
}
/* ================================================================
PALETTE — inject Obsidian CSS vars from sampled color
================================================================ */
function applyPalette(sampledHex) {
const scale = buildScale(sampledHex);
const lum = relativeLuminance(sampledHex);
const isLight = lum > 0.179;
const bg = sampledHex;
const [r, g, b] = hexToRGB(bg).map((v) => Math.round(v * 255));
const ordered = [
scale.paper, scale[50], scale[100], scale[150],
scale[200], scale[300], scale[400], scale[500],
scale[600], scale[700], scale[800], scale[850],
scale[875], scale[900], scale.black,
];
const baseOrder = isLight ? ordered : [...ordered].reverse();
const baseMap = {
"00": baseOrder[0], "05": baseOrder[1],
"10": baseOrder[2], "20": baseOrder[3],
"25": baseOrder[4], "30": baseOrder[5],
"35": baseOrder[6], "40": baseOrder[7],
"50": baseOrder[8], "60": baseOrder[9],
"70": baseOrder[10], "80": baseOrder[11],
"85": baseOrder[12], "90": baseOrder[13],
"100": baseOrder[14],
};
const [aR, aG, aB] = hexToRGB(scale[500]);
const [accentH, accentS, accentL] = rgbToHSL(aR, aG, aB);
// Concrete hex values — prevents CSS var() escaping our scope
const textNormal = baseMap["100"];
const textMuted = baseMap["85"];
const textFaint = baseMap["70"];
const colorAction = baseMap["60"];
const bgPrimary = baseMap["00"];
const bgSecond = baseMap["05"];
const bgHover = baseMap["10"];
const bgModifierBorder = isLight ? baseMap["30"] : baseMap["70"];
const bgModifierBorderHover = isLight ? baseMap["35"] : baseMap["65"];
const bgModifierBorderFocus = isLight ? baseMap["40"] : baseMap["60"];
const S = ".obsidian-custom-view-render, .obsidian-custom-view-editable .obsidian-custom-view-render";
const baseVars = Object.entries(baseMap)
.map(([k, v]) => ` --color-base-${k}: ${v.toUpperCase()};`)
.join("\n");
const css = `
${S} {
--color-bg: ${bg};
--color-gradient-start: rgba(${r},${g},${b},0);
--accent-h: ${accentH.toFixed(1)};
--accent-s: ${accentS.toFixed(1)}%;
--accent-l: ${accentL.toFixed(1)}%;
${baseVars}
--background-primary: ${bg};
--background-secondary: ${bgSecond};
--background-modifier-border: ${bgModifierBorder};
--background-modifier-border-hover: ${bgModifierBorderHover};
--background-modifier-border-focus: ${bgModifierBorderFocus};
--background-modifier-hover: ${bgHover};
--background-modifier-form-field: ${bgHover};
--table-border-color: ${bgModifierBorder};
--text-normal: ${textNormal};
--text-muted: ${textMuted};
--text-faint: ${textFaint};
--blockquote-border-color: ${textFaint};
--interactive-accent: ${textFaint};
--text-on-accent: ${isLight ? scale.black : scale.paper};
--text-on-accent-inverted: ${isLight ? scale.paper : scale.black};
color: ${textNormal};
background-color: ${bgPrimary};
}
${S} .theme-light, ${S} .theme-dark {
${baseVars}
--accent-h: ${accentH.toFixed(1)};
--accent-s: ${accentS.toFixed(1)}%;
--accent-l: ${accentL.toFixed(1)}%;
--text-normal: ${textNormal};
--text-muted: ${textMuted};
--text-faint: ${textFaint};
--link-color: ${textNormal};
--background-primary: ${bgPrimary};
--background-secondary: ${bgSecond};
--bases-table-header-background: ${bg};
--bases-table-header-color: ${textMuted};
--bases-embed-border-color: ${textMuted};
--icon-color-active: ${textNormal};
--caret-color: ${textMuted};
}`;
let styleEl = _container.querySelector("#flexoki-palette");
if (!styleEl) {
styleEl = document.createElement("style");
styleEl.id = "flexoki-palette";
_container.appendChild(styleEl);
}
styleEl.textContent = css;
// Update gradient to match sampled background color
const gradientEl = _container.querySelector("#gradientOverlay");
if (gradientEl) {
gradientEl.style.background = `linear-gradient(to bottom, transparent 32.77%, ${bg} 83.04%)`;
}
// Sync card background
// const card = document.querySelector(".movie-card");
// if (card) card.style.backgroundColor = ;
}
/* ================================================================
IMAGE → PALETTE
================================================================ */
const bgImg = _container.querySelector("#bgImg");
function onBgLoad() {
const sampled = sampleDominantColor(bgImg);
applyPalette(sampled || "#7baaba");
}
if (bgImg.complete && bgImg.naturalWidth > 0) {
onBgLoad();
} else {
bgImg.addEventListener("load", onBgLoad);
bgImg.addEventListener("error", () => applyPalette("#7baaba"));
}
/* ================================================================
TMDB HELPERS
================================================================ */
function tmdbReady() {
return Boolean(TMDB_ID) && Boolean(TMDB_API_KEY);
}
async function fetchTMDB(path) {
const sep = path.includes("?") ? "&" : "?";
const res = await fetch(
`${TMDB_BASE}${path}${sep}api_key=${TMDB_API_KEY}`,
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
/* ── Cast & Crew ─────────────────────────────────────────────── */
async function loadCast() {
const inner = _container.querySelector("#castRow .cast-inner") || _container.querySelector("#castRow");
if (!tmdbReady()) {
inner.innerHTML = `<span class="tmdb-loading">Set tmdbId and tmdbApiKey in frontmatter.</span>`;
return;
}
try {
const { cast, crew } = await fetchTMDB(`/movie/${TMDB_ID}/credits`);
const KEY_JOBS = new Set([
"Director",
"Director of Photography",
"Original Music Composer",
"Producer",
]);
const people = [
...cast
.slice(0, 10)
.map((p) => ({
name: p.name,
role: p.character,
photo: p.profile_path,
})),
...crew
.filter((p) => KEY_JOBS.has(p.job))
.slice(0, 5)
.map((p) => ({
name: p.name,
role: p.job,
photo: p.profile_path,
})),
];
inner.innerHTML = "";
for (const person of people) {
const card = document.createElement("div");
card.className = "cast-card";
if (person.photo) {
const img = document.createElement("img");
img.className = "cast-portrait";
img.src = `${TMDB_IMG}/w185${person.photo}`;
img.alt = person.name;
img.loading = "lazy";
img.onerror = () =>
img.replaceWith(makeAvatar(person.name));
card.appendChild(img);
} else {
card.appendChild(makeAvatar(person.name));
}
const info = document.createElement("div");
info.className = "cast-info";
info.innerHTML = `<p class="cast-name">${person.name}</p><p class="cast-role">${person.role || ""}</p>`;
card.appendChild(info);
inner.appendChild(card);
}
} catch (e) {
inner.innerHTML = `<span class="tmdb-loading">Could not load cast (${e.message}).</span>`;
}
}
function makeAvatar(name) {
const el = document.createElement("div");
el.className = "cast-avatar";
el.textContent = (name || "?")[0].toUpperCase();
return el;
}
/* ── Trailers ────────────────────────────────────────────────── */
async function loadTrailers() {
const row = _container.querySelector("#trailersRow");
if (!tmdbReady()) {
row.innerHTML = `<span class="tmdb-loading">Set tmdbId and tmdbApiKey in frontmatter.</span>`;
return;
}
try {
const { results } = await fetchTMDB(`/movie/${TMDB_ID}/videos`);
const trailers = results
.filter(
(v) =>
v.site === "YouTube" &&
["Trailer", "Teaser"].includes(v.type),
)
.slice(0, 4);
if (!trailers.length) {
row.innerHTML = `<span class="tmdb-loading">No trailers available.</span>`;
return;
}
row.innerHTML = "";
for (const t of trailers) {
const card = document.createElement("div");
card.className = "trailer-card";
const img = document.createElement("img");
img.className = "trailer-thumb";
img.src = `https://img.youtube.com/vi/${t.key}/hqdefault.jpg`;
img.alt = t.name;
img.loading = "lazy";
const overlay = document.createElement("div");
overlay.className = "trailer-dark-overlay";
card.appendChild(img);
card.appendChild(overlay);
card.addEventListener("click", () =>
window.open(
`https://www.youtube.com/watch?v=${t.key}`,
"_blank",
),
);
row.appendChild(card);
}
} catch (e) {
row.innerHTML = `<span class="tmdb-loading">Could not load trailers (${e.message}).</span>`;
}
}
loadCast();
loadTrailers();
/* -- SHOW MORE: render note body (frontmatter + score callout stripped), toggle like Albums -- */
(async () => {
try {
const _card = _container.querySelector(".movie-card") || _container;
const moreBtn = _card.querySelector(".movie-more");
const notesEl = _card.querySelector(".movie-notes");
if (!notesEl) return;
const wantName = _card.dataset.name || "";
let file = app.workspace.getActiveFile();
if ((!file && wantName) || (file && wantName && file.name !== wantName)) {
const byName = app.vault.getFiles().find(f => f.name === wantName);
if (byName) file = byName;
}
const fail = () => { if (moreBtn) moreBtn.remove(); notesEl.remove(); };
if (!file) return fail();
let body = await app.vault.read(file);
body = body.replace(/^---[\s\S]*?\n---\n?/, "");
body = body.replace(/^\s*((?:>.*\n?)+)\n?/, m => /dataviewjs|\[!abstract\]/.test(m) ? "" : m);
body = body.trim();
const meaningful = body.replace(/^#{1,6}\s.*$/gm, "").replace(/^#\S+\s*$/gm, "").replace(/^suggested by.*$/gim, "").replace(/\s+/g, "");
if (!meaningful) return fail();
const obs = require("obsidian");
notesEl.classList.add("markdown-rendered");
notesEl.innerHTML = "";
const comp = new obs.Component();
if (obs.MarkdownRenderer && obs.MarkdownRenderer.render) await obs.MarkdownRenderer.render(app, body, notesEl, file.path, comp);
else if (obs.MarkdownRenderer) await obs.MarkdownRenderer.renderMarkdown(body, notesEl, file.path, comp);
if (moreBtn) moreBtn.addEventListener("click", () => {
const open = _card.classList.toggle("notes-open");
moreBtn.textContent = open ? "SHOW LESS" : "SHOW MORE";
});
} catch (e) { console.error("[Custom Views notes]", e); }
})();
/* -- computed 0-100 score + verdict (mirrors the medium's scoring rubric) -- */
(() => {
try {
const _scard = _container.querySelector(".movie-card"); if (!_scard) return;
const el = _scard.querySelector(".media-score");
let _sf = app.workspace.getActiveFile();
const wn = _scard.dataset.name || "";
if (wn && (!_sf || _sf.name !== wn)) { const b = app.vault.getFiles().find(x => x.name === wn); if (b) _sf = b; }
const fm = _sf ? ((app.metadataCache.getFileCache(_sf) || {}).frontmatter || {}) : {};
const n = v => (typeof v === "number" && !isNaN(v)) ? v : 0;
const C = ["direction","screenplay","acting","cinematography","sound","editing","production_design","emotional_impact","rewatchability","personal_score"];
const sum = C.reduce((a, k) => a + n(fm[k]), 0);
const count = C.filter(k => n(fm[k])).length;
const bonus = (fm.favorite?3:0)+(fm.rewatched?2:0)+(fm.stuck_with_me?2:0);
const total = count >= 6 ? Math.min(100, Math.round(sum / count * 10) + bonus) : null;
const B = [[100,'A masterpiece - flawless in every respect.'],[95,'An all-time great with only unoverlookable minor flaws.'],[90,'Genre-defining, held back only slightly. An all-timer.'],[85,'A standout of its year - memorable, with very few flaws.'],[80,'Excellent and distinctive. Well worth your time.'],[75,'Above average, with several elevating elements.'],[70,'Solid, lifted by some standout element.'],[65,'Above average, but with noticeable flaws.'],[60,'Decent - some genuinely enjoyable parts.'],[55,'Passable, but disappoints on most fronts.'],[50,'The midpoint - average, forgettable.'],[45,'Subpar; falls short on most fronts.'],[40,'Poor - significant flaws, little to enjoy.'],[35,'Below average; flawed and unoriginal.'],[30,'Flat and typical; forgettable.'],[25,'Disappointing; problems outweigh the positives.'],[20,'Largely unenjoyable; few redeeming qualities.'],[15,'Severely flawed; not worth experiencing.'],[10,'Among the worst of its kind.'],[5,'Abysmal - a complete failure.'],[0,'Not worth mentioning.']];
const verdict = total === null ? "" : (B.find(b => total >= b[0]) || B[B.length - 1])[1];
if (el) {
if (total === null) el.innerHTML = `<span class="media-score-num" data-empty="1">Not yet scored</span><span class="media-verdict">${count}/6 core fields filled</span>`;
else el.innerHTML = `<span class="media-score-num">${total}</span><span class="media-verdict">${verdict}</span>`;
}
} catch (e) { console.error("[Custom Views score]", e); }
})();
Templater Template
<%*
/**
* TMDB Movie → Frontmatter (Obsidian Templater)
* Searches TMDB, writes full frontmatter, renames the note.
* Rendered as an Apple-TV-style card by the Custom Views "Movies" view.
*/
const API_KEY = (getComputedStyle(document.body).getPropertyValue("--tmdb-api-key") || "").trim().replace(/^["']|["']$/g, "");
const today = tp.date.now("YYYY-MM-DD");
const fileTitle = tp.file.title;
// 0) Importer handshake — if stardust-importer queued data for this note
// (keyed by filename), use the known TMDB id and skip the interactive steps.
const __P = app.plugins.plugins["stardust-importer"]?.pending?.[fileTitle];
let query = fileTitle;
let chosen = null;
let movieId;
if (__P) {
movieId = __P.tmdbId;
query = (__P.title || fileTitle).trim();
} else {
// 1) Ask for movie name ------------------------------------------------
query = await tp.system.prompt("Enter movie name (leave empty for file name):");
if (!query || !query.trim()) query = fileTitle;
query = query.trim();
// 2) Search TMDB -------------------------------------------------------
const searchUrl =
"https://api.themoviedb.org/3/search/movie" +
`?api_key=${API_KEY}&query=${encodeURIComponent(query)}` +
"&include_adult=false&language=en-US&page=1";
let searchResponse;
try { searchResponse = await tp.web.request(searchUrl); }
catch (e) { tR += `TMDB search error: ${e}`; return; }
const results = searchResponse?.results || [];
if (!results.length) { tR += `No results for "${query}".`; return; }
const limited = results.slice(0, 15);
const display = limited.map(r => {
const title = r.title || r.original_title || "Unknown";
const year = (r.release_date || "").slice(0, 4) || "????";
const lang = (r.original_language || "").toUpperCase();
return `${title} (${year}) [${lang}]`;
});
// 3) Pick movie --------------------------------------------------------
chosen = await tp.system.suggester(display, limited, true, "Pick a movie");
if (!chosen) { tR += "Selection cancelled."; return; }
movieId = chosen.id;
}
// 4) Fetch details / credits / images / external IDs ---------------------
let details, credits, images, externalIds;
try {
details = await tp.web.request(`https://api.themoviedb.org/3/movie/${movieId}?api_key=${API_KEY}&language=en-US`);
credits = await tp.web.request(`https://api.themoviedb.org/3/movie/${movieId}/credits?api_key=${API_KEY}&language=en-US`);
images = await tp.web.request(`https://api.themoviedb.org/3/movie/${movieId}/images?api_key=${API_KEY}`);
externalIds = await tp.web.request(`https://api.themoviedb.org/3/movie/${movieId}/external_ids?api_key=${API_KEY}`);
} catch (e) { tR += `TMDB details error: ${e}`; return; }
// 5) Extract -------------------------------------------------------------
const movieTitle = (details.title || details.original_title || (chosen && chosen.title) || query || fileTitle).trim();
const year = (details.release_date || "").slice(0, 4) || "";
const genres = (details.genres || []).map(g => g.name).filter(Boolean);
const plot = (details.overview || "").replace(/"/g, '\\"');
const runtime = details.runtime || "";
const published = details.release_date || "";
const imdbId = details.imdb_id || (externalIds && externalIds.imdb_id) || "";
const tmdbId = movieId;
// Link to an existing person note when one exists (first 3 always linked)
async function noteExists(name) {
for (const folder of ["References", ""]) {
const path = folder ? `${folder}/${name}.md` : `${name}.md`;
try { if (await tp.file.exists(path)) return true; } catch (e) {}
}
return false;
}
async function people(names) {
const out = [];
for (let i = 0; i < names.length; i++) {
if (i < 3) out.push(`[[${names[i]}]]`);
else out.push((await noteExists(names[i])) ? `[[${names[i]}]]` : names[i]);
}
return out;
}
const crew = credits?.crew || [];
const dedupe = arr => { const s = new Set(); return arr.filter(n => n && !s.has(n) && s.add(n)); };
const directors = await people(dedupe(crew.filter(m => (m.job || "").toLowerCase() === "director").map(m => m.name)));
const writers = await people(dedupe(crew.filter(m => {
const d = (m.department || "").toLowerCase(), j = (m.job || "").toLowerCase();
return d === "writing" || j.includes("writer") || j.includes("screenplay") || j.includes("story") || j.includes("teleplay");
}).map(m => m.name)));
const cast = await people((credits?.cast || []).slice(0, 8).map(c => c.name).filter(Boolean));
// Best-voted poster + backdrop
function bestImage(arr, fallbackPath) {
if (arr && arr.length) {
arr.sort((a, b) => (b.vote_count || 0) - (a.vote_count || 0));
if (arr[0]?.file_path) return `https://image.tmdb.org/t/p/original${arr[0].file_path}`;
}
return fallbackPath ? `https://image.tmdb.org/t/p/original${fallbackPath}` : "";
}
const coverURL = bestImage(images?.posters, details.poster_path);
const backdropURL = bestImage(images?.backdrops, details.backdrop_path);
// 6) Watched date ----------------------------------------------
let lastDate = __P ? (__P.watchedAt || today) : await tp.system.prompt("Last watched date (YYYY-MM-DD)", today);
if (!lastDate || !/^\d{4}-\d{2}-\d{2}$/.test(lastDate.trim())) lastDate = today;
lastDate = lastDate.trim();
// 7) Rename to "Movies - Title" ----------------------------------------
const colonFixed = movieTitle.replace(/:/g, " - ");
let sanitized = colonFixed.replace(/[\/#%&{}<>*?$!'"@+`|=]/g, "").trim();
if (!sanitized) sanitized = fileTitle;
const requiresAlias = sanitized !== movieTitle;
const newName = sanitized;
if (newName !== fileTitle) await tp.file.rename(newName);
// 8) Build YAML ----------------------------------------------------------
const L = [];
L.push("---");
L.push("connections:");
L.push("categories:");
L.push(' - "[[Movies]]"');
if (genres.length) { L.push("genre:"); genres.forEach(g => L.push(` - "[[${g}]]"`)); } else L.push("genre: []");
if (directors.length) { L.push("director:"); directors.forEach(d => L.push(` - "${d}"`)); } else L.push("director: []");
if (writers.length) { L.push("writers:"); writers.forEach(w => L.push(` - "${w}"`)); } else L.push("writers: []");
if (cast.length) { L.push("cast:"); cast.forEach(c => L.push(` - "${c}"`)); } else L.push("cast: []");
L.push(coverURL ? `cover: "${coverURL}"` : "cover: []");
if (backdropURL) { L.push("backdrop:"); L.push(` - "${backdropURL}"`); } else L.push("backdrop: []");
L.push(`description: "${plot}"`);
L.push(`year: ${year}`);
L.push(`runtime: ${runtime}`);
L.push(`published: ${published}`);
L.push(`created: ${today}`);
L.push(`last: ${lastDate}`);
L.push(`imdbId: ${imdbId}`);
L.push(`tmdbId: ${tmdbId}`);
L.push("via:");
// Scores — each 0–10 ----------------------------------------------------
L.push("direction: ");
L.push("screenplay: ");
L.push("acting: ");
L.push("cinematography: ");
L.push("sound: ");
L.push("editing: ");
L.push("production_design: ");
L.push("emotional_impact: ");
L.push("rewatchability: ");
L.push("personal_score: ");
// Flags ------------------------------------------------------------------
L.push("favorite: false");
L.push("rewatched: false");
L.push("stuck_with_me: false");
L.push("status:");
L.push("---");
const body = [
"",
"> [!abstract] Score",
"> ```dataviewjs",
"> const p = dv.current();",
'> const n = v => (typeof v === "number" && !isNaN(v)) ? v : 0;',
'> const C = ["direction","screenplay","acting","cinematography","sound","editing","production_design","emotional_impact","rewatchability","personal_score"];',
"> const sum = C.reduce((a,k)=>a+n(p[k]),0);",
"> const count = C.filter(k=>n(p[k])).length;",
"> const bonus = (p.favorite?3:0) + (p.rewatched?2:0) + (p.stuck_with_me?2:0);",
"> const total = count >= 6 ? Math.min(100, Math.round(sum/count*9 + bonus/7*10)) : null;",
"> const B = [[100,'A masterpiece — flawless in every respect.'],[95,'An all-time great with only unoverlookable minor flaws.'],[90,'Genre-defining, held back only slightly. An all-timer.'],[85,'A standout of its year — memorable, with very few flaws.'],[80,'Excellent and distinctive. Well worth your time.'],[75,'Above average, with several elevating elements.'],[70,'Solid, lifted by some standout element.'],[65,'Above average, but with noticeable flaws.'],[60,'Decent — some genuinely enjoyable parts.'],[55,'Passable, but disappoints on most fronts.'],[50,'The midpoint — average, forgettable.'],[45,'Subpar; falls short on most fronts.'],[40,'Poor — significant flaws, little to enjoy.'],[35,'Below average; flawed and unoriginal.'],[30,'Flat and typical; forgettable.'],[25,'Disappointing; problems outweigh the positives.'],[20,'Largely unenjoyable; few redeeming qualities.'],[15,'Severely flawed; not worth experiencing.'],[10,'Among the worst of its kind.'],[5,'Abysmal — a complete failure.'],[0,'Not worth mentioning.']];",
"> const verdict = total === null ? '' : (B.find(b => total >= b[0]) || B[B.length-1])[1];",
"> dv.paragraph(total === null ? `_Not yet scored — ${count}/6 core fields._` : `**${total}** — ${verdict}`);",
"> ```",
"",
"## Premise",
"",
"## First Watch",
"",
"## Standout Scenes",
"",
"## Direction & Craft",
"",
"## Performances",
"",
"## Themes",
"",
"## Verdict",
""
];
tR += L.join("\n") + "\n" + body.join("\n");
-%>
Field Assembly
Want a card like this in your own vault? Three plugins do the heavy lifting.
- Custom Views: the renderer. Make a view, set its rule to
categoriescontainsMovies, paste the HTML block into the template field and the JavaScript block into the JS field. Leave the CSS field empty. - Templater: the note-maker. Paste the Templater block into a template file and bind it to a hotkey or a folder template.
- Stardust Importer: mine, in the community store. It holds the TMDB key and publishes it as the
--tmdb-api-keyCSS variable the view reads. Without it, supply your own: a one-line snippet,body { --tmdb-api-key: "your-key"; }, and the card picks it up.
That is the whole apparatus: a rule, a template, a script, and a key on the body.
