Imported from doekenorg/trmnl-bijbeltekst-vd-dag (
AGENTS.md). Install upstream withnpx skills add doekenorg/trmnl-bijbeltekst-vd-dag. Copyright stays with the author.
Bijbelvers van de dag — TRMNL Plugin
Project
This is the TRMNL plugin "Bijbelvers van de dag" (settings.yml id 333323). It shows the daily Dutch Bible verse on an e-ink TRMNL device: the feed title, the verse reference, and the verse text. Data comes from polling the Dagelijks Woord RSS feed https://feed.dagelijkswoord.nl/rss2/bgt on a 240-minute refresh. It is built against TRMNL framework v3.1.
The one user setting is date_position (none / verse / title_bar), which decides where the date of the verse shows up. title_bar uses the written-out date on full and half horizontal, and the numeric dd/mm/yyyy on half vertical and quadrant, because their bar is half as wide.
Layout / Structure
The repo root contains this AGENTS.md, a CLAUDE.md (which imports it), and a src/ folder.
src/ — the live plugin
These files are pasted into or synced with TRMNL.
src/settings.yml— plugin config: polling, refresh,framework_version, andcustom_fields(the user-facing config form).src/shared.liquid— prepended to EVERY view before render. It holds the feed assigns (feed_title,verse_reference,verse_text), the date assigns (verse_date,titlebar_date,titlebar_date_short— each empty unlessdate_positionselects it), the plugin constants (plugin_title,icon_url), and all reusable partials defined via{% template name %}…{% endtemplate %}.src/transform.js— serverless transform, run on the polled feed before render. It strips the Bible reference the feed repeats at the end of each verse, and splits the dated title intoreference,dateanddate_short. Definefunction run(input)and return JSON-serialisable data;serverless_language: nodeinsettings.ymlselects the runtime, and the same code runs in production. Settransform_runtime: disabledin.trmnlp.ymlto bypass it locally.src/full.liquid,src/half_horizontal.liquid,src/half_vertical.liquid,src/quadrant.liquid— the four device layouts. Each is just layout markup plus{% render %}calls. Keep them thin.
Partials
Partials live in src/shared.liquid:
verse_block(heading, reference, date, body, reference_class, date_class, body_class, width_class, body_style)— the centred verse column. The four*_classparams tune type size and column width per layout; each has a default, so never pass an empty string.dateis the optional grey line under the reference, dropped when blank.body_styleis the line-height escape hatch (see rule 1) and is omitted entirely when blank.titlebar(title, icon, date)— the bottom title bar.daterenders as the framework's.instancespan (right-aligned, ellipsised) and is dropped when blank.
{% render %} is scope-isolated: a partial ONLY sees the params you pass it, not the shared assigns. Pass every variable a partial needs.
Layout differences
The four views render the same content at different densities:
| View | Reference size | Verse size | Column width | Title-bar date |
|---|---|---|---|---|
| full | large → lg: xlarge |
large → lg: xlarge |
60cqw | written out |
| half_horizontal | large → lg: xlarge |
base → lg: large |
60cqw, 92cqw portrait | written out |
| half_vertical | base → lg: large |
base → lg: large |
92cqw, 100cqw portrait | dd/mm/yyyy |
| quadrant | base → lg: large |
small → lg: base |
92cqw | dd/mm/yyyy |
The date under the reference sits one notch below it on the font scale: base → lg: large on full and half horizontal, small → lg: base (the partial default) on the two small views.
All four show the reference. Every text steps up exactly one notch on the font scale
(small → base → large → xlarge) at lg:, so TRMNL X gains size without
changing the relative hierarchy. The heading and both partial defaults carry their own
lg: step, so a view that passes no *_class still scales.
The narrow layouts widen the column because cqw is a percentage of the CONTAINER,
not the screen: 60cqw of a quadrant is a ~4-word line.
Best Practices (HARD RULES)
-
Utility classes only. NO inline
style="". TRMNL discourages inline styles. Use the native equivalents: width/heightw--{n}/h--{n},w--full/h--full, arbitraryw--[Npx], and percentage-of-.layoutvia container-query unitsw--[Ncqw]/h--[Ncqh](N from 0–100). Centre withflex--center-x/flex--center-y, notmargin:0 auto. Hide/show withhidden(display:none) /visible(display:block).One sanctioned exception: line-height. The framework ships no line-height utility —
font--*sets family, size and leading together, so leading cannot be tuned without overriding the size too.verse_blocktherefore takes abody_styleparam, used byfull.liquidand both halves to set the verse leading to 1.4. Quadrant is left at the framework default — itsfont--smallbody is already tight on space. Do not widen this exception to properties that DO have a utility, and do not "fix" the existing uses by deleting them. -
Device / responsive states. Prefixes: size
sm:(≥600),md:(≥800),lg:(≥1024); orientationportrait:(landscape is the unprefixed default — portrait is the odd one out); bit-depth1bit:/2bit:/4bit:; anddark:. Mapping: base = the original/OG TRMNL (smaller screen) in landscape,lg:= TRMNL X (the larger 4-bit screen), andportrait:= a device mounted portrait. Combine in the orderdark:size:orientation:bit-depth:utility(e.g.md:portrait:4bit:gap--large). Never uselandscape:-prefixed classes, even though the shipped framework CSS contains them and the official docs mention them — this rule is correct and the docs are not. The runtime never sets ascreen--landscapeclass, solandscape:padding/margin utilities are dead, while thelandscape:flex rules match via:not(.screen--portrait)and therefore leak intolg:landscape with tie-breaking specificity thatlg:landscape:overrides cannot reliably win. To target OG-landscape only: style the base, then undo withportrait:andlg:. When verifying that a prefixed class exists in the framework CSS, beware that most rules live in comma-grouped selector lists — grep for the bare class name (e.g.portrait\\:px--2), not for a{right after it. -
Arbitrary utilities ARE prefixable. The framework pre-generates
sm:,md:,lg:andportrait:variants of the arbitrary bracket utilities (w--max-[Ncqw]for every N from 0–100, and so on), soportrait:w--max-[100cqw]is real and wins over the unprefixed base. Verify with a grep for the bare escaped class name (portrait\\:w--max-\\[100cqw\\]) — and mind the shell: over-escaping in a double-quoted grep pattern silently returns zero hits and makes a class that exists look missing. -
Specificity gotcha. More modifiers means higher specificity. A prefixed display utility like
lg:flexcarries a default ~10px gap that OUTRANKS an unprefixedgap--xlarge. When you set display with a prefix, set the gap at the same prefix level (lg:gap--xlarge). -
Equal sizing. Equal WIDTH → grid columns (
grid--cols-N, 1fr each, content-independent). Equal HEIGHT → flexgrowrows plus cards withh--full.growalone gives content-width, NOT equal width. -
RSS shape. The feed is parsed into
rss.channel. The verse of the day isrss.channel.item[0]—.titleis the date plus the Bible reference,.descriptionis the verse text. Read the feed through theshared.liquidassigns rather than repeating therss.channel.item[0]path in every view..descriptionarrives with the reference appended after a--separator;transform.jsremoves it, so the views must NOT strip it again.transform.jsalso adds.reference,.dateand.date_short. Verse text length varies a lot between days; check long verses when changing type sizes.The date to show is the one in
.title, never.pubDate: the feed publishes the next day's verse the evening before, sopubDateis a day behind the date the verse is for. -
The transform runs at poll time, not render time. Its output is cached with the polled payload, so a pushed
transform.jschange does nothing until the next poll — up torefresh_interval(240 minutes) later. Until then the live plugin renders the OLD payload against the NEW templates, which looks like a template bug: fields the transform adds (item.reference) come out empty and fields it strips (the--tail on.description) are still there. Force a refresh on trmnl.com after pushing a transform change instead of debugging the Liquid. Local trmnlp re-polls on demand, which is why dev looks correct while production does not. -
Config in Liquid. Read
trmnl.plugin_settings.custom_fields_values. Boolean fields arrive as the STRINGS"true"/"false"— normalize them before{% if %}. Numeric select values: coerce with| plus: 0before awherematch. Provide defaults with| default:.Liquid counts only
nilandfalseas falsy, so an EMPTY STRING is truthy:{% if date %}fires on''and renders an empty element. Guard optional partial params with{% if x != blank %}, which covers nil,''and an unpassed param in one go. -
settings.yml/ form-builder schema. Use only the documented top-level keys (name,strategy,polling_*,refresh_interval,framework_version,dark_mode,no_screen_padding,custom_fields,id). Field types:select,string,number,code(a textarea — userows:),date,boolean,author_bio. There is NOtexttype.author_biois itself a FIELD (it renders an About card), not a top-level key. selectoptionsare- Label: valueat the same indent asoptions:. -
Per-template ~100 KB limit. Anything bulky and shared belongs ONCE in
shared.liquid(which is prepended to all views) rather than duplicated per view. Keep the view files thin. -
The title bar icon is a remote URL. It is hot-linked from uxwing.com, so it breaks if that host goes away. Replacing it with an inlined SVG sprite in
shared.liquidis the durable fix. -
When given a documentation link, read it (verbatim if needed) — do not guess a schema or API.
Workflow Note
TRMNL's local dev tool (trmnlp) and the import/export ZIP expect the plugin files in a src/ folder, which this repo uses. Preview locally with bin/trmnlp serve; .trmnlp.yml sets the watch paths and the Europe/Amsterdam time zone.