In my last post about TRMNL I wrote about using TRMNL’s first-party plugin tools to create a private plugin and I fed it JSON data from a Craft CMS website. That whole process was relatively easy to pull off and probably the way most people should be creating plugins for TRMNL. However, before I got into the first-party plugin setup I found another approach to getting my data onto a TRMNL screen.

I was thinking about things that the TRMNL can replace or improve for me and my family and one thing that came to mind is the lunch schedule for my kids. Every month their grade school sends out a printed sheet with a calendar of the lunches that are planned, along with a list of all half days and days off for the month. Every month my wife takes this calendar and hand writes all of the lunches onto another piece of paper that also includes the name of that day’s "special" for each of my kids.

A "special" is what the school calls classes that rotate throughout the week, like PE, Library, Music, and Art. Each kid is assigned a different special—or two—for each day, so having this written down makes the difference between wearing sneakers and bringing back a library book. Specials rotate on a 6-day cycle and sometimes holidays and snow days mess with that 6-day loop.

From Paper to Website to E-Ink

Before writing a proper TRMNL plugin I saw that you can point to a URL and TRMNL’s server can essentially take a screenshot of what it sees. To test this out I pointed to my homepage and let it take a screenshot:

Trmnl com wbrowar

It seems to do a good job at rendering CSS and capturing what’s on screen. While my homepage isn't a great example because there's not enough contrast for a 2-color screen, I could totally see this working when you design for that.

I put together a really quick page template and uploaded that to my Craft CMS website to test out a few things. First, I found that the dimensions of a TRMNL screen is 800 x 480, so I created a fixed width and height to work in. Then I started playing around with things like line thickness and different shades of gray to see how that gets rendered on e-ink. I found that just using black on white seemed to work the best, but you can get away with some shades of gray in some spots that were background or design elements.

Crafting for the Small Screen

When it came time to start creating the real lunch schedule I decided to set things up so that if I liked this approach it would be easier to add on more pages in the future. I started by creating a Twig layout that could work for all TRMNL pages going forward.

templates/layouts/trmnl-layout.twig
<!DOCTYPE html>
<html lang="en-US">
    <head>
        <meta charset="utf-8"/>
        <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>

        {% set cssAsset = siteAsset('css') %}
        {% if cssAsset ?? false %}
            <link rel="stylesheet" href="{{ cssAsset }}"/>
        {% endif %}
    </head>
    <body class="[ trmnl-layout : body ] ltr">
        {{ craft.blitz.includeDynamic('includes/admin-bar.twig', { entryUri: entry.uri ?? '' }) }}

        <main class="[ trmnl-layout : content ]">
            {% block content %}
            {% endblock %}
        </main>

        {% css adminBarCssFile({ contents: true }) %}
        {% css adminBarOnPageCss() %}
        {% js adminBarJsFile() with { defer: true, type: 'module' } %}

        <script type="module" src="{{ siteAsset('js') }}"></script>
    </body>
</html>

If you've used Craft CMS and Twig this might look familiar. It’s essentially a generic HTML skeleton, it loads in my global CSS and JavaScript (using a siteAsset() method from a custom module), loads an Admin Bar, and provides a content block to override with page content.

Following my site’s CSS conventions I set up a CSS file that pairs with this Twig template:

templates/layouts/trmnl-layout.css
@layer trmnl-layout {
  .trmnl-layout {
    --trmnl-width: 800px;
    --trmnl-height: 480px;
    --border-radius: 5px;
    margin-inline: auto;
    background-color: var(--color-gray);

    &.content {
      container-name: content;
      container-type: size;
      width: var(--trmnl-width);
      height: var(--trmnl-height);
      background-color: var(--color-white);
      font-size: 1.3rem;
      color: var(--color-black);
      overflow: hidden;

      & .header-bar {
        padding: 5px;
        background-color: var(--color-black);
        border-radius: var(--border-radius);
        color: var(--color-white);
      }
      & .icon {
        width: var(--icon-width, 25px);
      }
    }
  }
}

The .trmnl-layout class sets a fixed width and height, and throws a gray background onto the page. The .content styles set some very basic global styles that I would want to start from across all TRMNL pages. The other CSS selectors are also globals that I use later on.

Page Template

In the CMS, I created a new section, called TRMNL Page and a Twig template to render it at templates/pages/trmnl.twig. I was thinking that in order to content manage this schedule I'd need to come up with some fields that are specific to this page, so I created a new Entry Type, called TRMNL – Kid’s Schedule, and added it to the TRMNL Page section.

So this approach means I have one section for all TRMNL pages and then by switching Entry Types it would decide what fields would be available. I’d also pair each Entry Type with a Twig template to render its content.

templates/pages/trmnl.twig
{% extends 'layouts/trmnl-layout.twig' %}

{% set headers = craft.app.request.getHeaders() %}

{% if not currentUser and not (headers.get('trmnl-auth-secret') == parseEnv('$TRMNL_AUTH_SECRET')) %}
  {% redirect '/' %}
{% endif %}

{% block content %}
  {% if entry.type == 'trmnlKidsSchedule' %}
    {% include 'trmnl/kids-school-schedule.twig' %}
  {% endif %}
{% endblock %}

In the trmnl.twig template I also used the same header check that I used in the JSON-based setup. This used the same secret that is stored with my site’s other secrets. Just like with the other private plugin, TRMNL can pass along custom headers when taking a screenshot.

Schedule Layout

I took a little time to lay out the lunch schedule. As I tried something locally I would push the changes up to my live site, go into TRMNL and trigger a manual refresh, then use the button on the back of my TRMNL device to see the changes on the screen.

At first I just made a list with the day on the left, the lunch menu, and then icons for the specials on the right. Being lazy, I used emojis at first and quickly realized they don't render as nicely on the black and white screen.

Trmnl page draft 1

After a little more time with it, I came up with a two-column layout that highlights the current day and then shows the next 5 days in the schedule. At this point I was doing some HTML and CSS to lay things out and Twig with some mock data. For the icons I wound up using the icons built into Craft CMS. I’m not sure if these are meant to be publicly available so if I were doing this for public consumption I might instead use some SVGs sourced from Heroicons or some other place on the web.

The layout seemed to hold up pretty well when rendered for the e-ink display.

Trmnl page rendered

Sprinkling in Some Content Management

Finally, I started thinking about how to manage this menu. For the most part my wife or I would be updating this list once per month with about 20 items at a time. I didn’t want to create a separate channel and I thought that even a matrix field might wind up adding a bunch of extra entries and fields that I didn’t want to clutter up my actual website with. So although there are some author experience caveats, I went ahead and used a table field to handle everything.

Trmnl page cms

It all makes sense once you get used to it, but this isn’t how I would set things up for a client. To document this, I created a guide and stuck it on the table field so I can refer back to it next month.

Trmnl page cms guide

With the field in place and with some dummy content filled in, I wired up my Twig template to pull in all of the data from the field. Now I could use Live Preview or I could go directly to the entry page to see the result.

Trmnl page cms front end

To fully share everything, here are the Twig template and CSS that I used when putting this all together:

templates/trmnl/kids-school-schedule.twig
{% import _self as self %}

{% set art = 'pen-paintbrush' %}
{% set library = 'book' %}
{% set music = 'piano' %}
{% set pe = 'basketball' %}

{% set days = [
  'Sunday',
  'Monday',
  'Tuesday',
  'Wednesday',
  'Thursday',
  'Friday',
  'Saturday',
] %}

{% set kids = [
  {
    name: 'Kid 1',
    specials: [
      [pe], [art], [pe], [art, music], [pe], [library],
    ],
  },
  {
    name: 'Kid 2',
    specials: [
      [pe], [art], [pe, music], [library], [pe], [art],
    ],
  },
] %}

{% set dates = [] %}
{% set previousDate = null %}
{% set previousSpecialsDay = null %}
{% set todayIndex = 0 %}

{% for row in entry.kidsSchoolSchedule %}
  {# Figure out the current date and specials day based on previous day. #}
  {% if row.date ?? false %}
    {% set rowDate = row.date %}
  {% elseif previousDate ?? false %}
    {% set rowDate = previousDate|date_modify("+1 day") %}
  {% endif %}

  {% if row.specialsDay ?? false %}
    {% set rowSpecialsDay = row.specialsDay|integer %}
  {% elseif previousSpecialsDay ?? false %}
    {% set rowSpecialsDay = previousSpecialsDay + 1 %}
  {% endif %}

  {% if rowDate ?? false and rowSpecialsDay ?? false %}
    {# If the assumed rowDate is a Saturday, bump the date to the next Monday. #}
    {% if rowDate|date('N') > 6 %}
      {% set rowDate = rowDate|date_modify('next Monday') %}
    {% endif %}

    {# If the date matches today, store the index for later. #}
    {% set isToday = rowDate|date('Y-m-d') == now|date('Y-m-d') %}
    {% if isToday %}
      {% set todayIndex = loop.index0 %}
    {% endif %}

    {# Reset specials day back to first day when at the end of the loop. #}
    {% if rowSpecialsDay > 6 %}
      {% set rowSpecialsDay = 1 %}
    {% endif %}

    {% set dates = dates|merge([{
      date: isToday ? 'Today' : days[rowDate|date('N') - 1] ~ rowDate|date(' n/j'),
      description: row.lunchOrDescription,
      isHoliday: row.holiday == '1',
      specialsDay: rowSpecialsDay
    }]) %}

    {# Store the previous date and specials day for the next loop iteration. #}
    {% set previousDate = rowDate %}
    {% set previousSpecialsDay = rowSpecialsDay %}
  {% endif %}
{% endfor %}

{% set todayInfo = dates[todayIndex] %}
{% set nextWeekInfo = dates|slice(todayIndex + 1, 5) %}

<div class="[ trmnl-school-schedule ]" style="--kid-count: {{ kids | length }};">
  <div class="[ grid ]">
    {% if todayInfo.isHoliday %}
      <div class="holiday">
        <p class="date header-bar">Today</p>
        <p class="holiday">{{ todayInfo.description }}</p>
      </div>
    {% else %}
      <div class="today">
        <h2 class="date header-bar">Today’s Lunch</h2>
        <p class="lunch">{{ todayInfo.description }}</p>
        <div class="specials">
          <h2 class="header-bar">Today’s Specials – {{ todayInfo.specialsDay }}</h2>
          {% for kid in kids %}
            <p><span>{{ kid.name }}</span><span>{{ self.specials(kids, loop.index0, todayInfo.specialsDay) }}</span></p>
          {% endfor %}
        </div>
      </div>
    {% endif %}

    <div class="next-week">
      <ul>
        <li class="header-bar">
          <h2>Upcoming Lunch</h2>
          {% for kid in kids %}
            <h2>{{ kid.name }}</h2>
          {% endfor %}
        </li>
        {% for day in nextWeekInfo %}
          <li>
            {% if day.isHoliday %}
              <div class="holiday">
                <div>
                  {{ self.icon('party-horn') }}
                </div>
                <div>
                  <h3>{{ day.date }}</h3>
                  <p>{{ day.description }}</p>
                </div>
              </div>
            {% else %}
              <div>
                <h3>{{ day.date }}</h3>
                <p>{{ day.description }}</p>
              </div>

              {% for kid in kids %}
                <p class="specials">{{ self.specials(kids, loop.index0, day.specialsDay) }}</p>
              {% endfor %}
            {% endif %}
          </li>
        {% endfor %}
      </ul>
    </div>
  </div>
</div>

{% macro icon(name) %}
  {{ svg('@appicons/' ~ name ~ '.svg')|attr({ class: 'icon' }) }}
{% endmacro %}

{% macro specials(kids, kidIndex, dayIndex) %}
  {% for special in kids[kidIndex].specials[dayIndex - 1] %}
    {{ self.icon(special) }}
  {% endfor %}
{% endmacro %}
templates/trmnl/trmnl-school-schedule.css
@layer trmnl-page {
  .trmnl-school-schedule {
    display: grid;
    grid-template-rows: 1fr;
    gap: var(--lg);
    padding: var(--md);
    height: 100%;

    & > h1 {
      font-size: 1.7rem;
      text-box: cap alphabetic;
    }

    .specials {
      white-space: nowrap;

      & svg {
        display: inline-block;
      }
    }

    & > .grid {
      display: grid;
      grid-template-columns: 25% 1fr;
      align-items: stretch;
      gap: var(--xl);

      & > .today {
        display: grid;
        grid-template-columns: 1fr;
        grid-template-rows: max-content 1fr max-content;
        gap: var(--xl);

        & h2 {
          font-size: 1rem;
          font-weight: var(--font-bold);
        }
        & p {
          text-box: cap alphabetic;
        }

        & .lunch {
          font-size: 2rem;
          line-height: 1.3;
          text-wrap: balance;
        }

        & .specials {
          & h2 {
            margin-block-end: var(--lg);
          }
          & > p {
            display: flex;
            justify-content: space-between;
            gap: var(--lg);
            font-size: 1.6rem;
          }
        }
      }

      & > .holiday {
        & .holiday {
          font-size: 2rem;
          text-wrap: balance;
        }
      }

      & > .next-week {
        & > ul {
          display: grid;
          grid-template-columns: 1fr repeat(var(--kid-count, 1), max-content);
          gap: var(--xl);
          margin: 0;
          padding: 0;

          & > li {
            display: grid;
            grid-template-columns: subgrid;
            align-items: center;
            grid-column: 1 / -1;

            & h2 {
              font-size: 1rem;
              font-weight: var(--font-bold);
            }
            & h3 {
              margin-block-end: var(--sm);
              font-size: 1rem;
              text-box: cap alphabetic;
            }

            & :is(p, span) {
              line-height: 1;
              text-box: cap alphabetic;
              text-wrap: balance;
            }

            & .holiday {
              display: flex;
              align-items: center;
              gap: var(--md);
              grid-column: 1 / -1;
              padding: 8px;
              border: 2px solid var(--color-gray);
              border-radius: var(--border-radius);
            }
          }
        }
      }
    }
  }
}

A lot of the CSS properties in here are specific to my site, but they should be pretty easy to replace if you wanted to try this code out for yourself.

Trmnl kids schedule

TRMNL has a nice design system you can use to save you time when creating this kind of layout, but I really liked the challenge of writing CSS for a two-color, low resolution screen. Working with Twig was also something I felt more comfortable doing over doing data management via Liquid, however, I think I could have figured out how to get to the same result after noodling with Liquid a little more.

There is one big difference in that when using TRMNL’s screenshot plugin you can only do a fullscreen layout. So as of right now I wouldn’t be able to make a smaller version of this that could work on one of TRMNL’s split screen layouts. That's fine for this kid’s lunch schedule, but a good thing to think about before the next plugin.

The only other thing I noticed is that the fonts are very close but rendered slightly different on the TRMNL than they do in the browser. I suspect that’s because my site uses a font stack based on system-ui and sans-serif so whatever the system default font is in the tool that captures TRMNL’s screenshots must be different. I could pull in a custom font here or try to match the TRMNL default UI font, so that might be something I look into for the future.

Finally, a side effect of creating widgets like this and hosting them on my website is that if I really wanted to I could tweak that one file that checks for headers and turn this into a web app that I can view from my phone’s browser. Having the kid’s lunch schedule on the go might not be something I need to refer to every day, but it’s also worth thinking about for future ideas.

This was another fun adventure with my TRMNL and I’m really looking forward to playing around with it some more.

🥪