feat(#149): add css_name and css_variables to DeviceModel and update related views

This commit is contained in:
Benjamin Nussbaum 2026-02-17 21:10:54 +01:00
parent 89a2edfcbb
commit d884ac0a58
15 changed files with 285 additions and 26 deletions

View file

@ -184,7 +184,7 @@ class GenerateDefaultImagesCommand extends Command
};
// Determine device properties from DeviceModel
$deviceVariant = $deviceModel->name ?? 'og';
$deviceVariant = $deviceModel->css_name ?? $deviceModel->name ?? 'og';
$colorDepth = $deviceModel->color_depth ?? '1bit'; // Use the accessor method
$scaleLevel = $deviceModel->scale_level; // Use the accessor method
$darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode
@ -196,6 +196,7 @@ class GenerateDefaultImagesCommand extends Command
'deviceVariant' => $deviceVariant,
'colorDepth' => $colorDepth,
'scaleLevel' => $scaleLevel,
'cssVariables' => $deviceModel->css_variables,
])->render();
}
}

View file

@ -12,8 +12,10 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
final class FetchDeviceModelsJob implements ShouldQueue
{
@ -209,12 +211,41 @@ final class FetchDeviceModelsJob implements ShouldQueue
$attributes['palette_id'] = $firstPaletteId;
}
$attributes['css_name'] = $this->parseCssNameFromApi($modelData['css'] ?? null);
$attributes['css_variables'] = $this->parseCssVariablesFromApi($modelData['css'] ?? null);
DeviceModel::updateOrCreate(
['name' => $name],
$attributes
);
}
/**
* Extract css_name from API css payload (strip "screen--" prefix from classes.device).
*/
private function parseCssNameFromApi(mixed $css): ?string
{
$deviceClass = is_array($css) ? Arr::get($css, 'classes.device') : null;
return (is_string($deviceClass) ? Str::after($deviceClass, 'screen--') : null) ?: null;
}
/**
* Extract css_variables from API css payload (convert [[key, value], ...] to associative array).
*/
private function parseCssVariablesFromApi(mixed $css): ?array
{
$pairs = is_array($css) ? Arr::get($css, 'variables', []) : [];
if (! is_array($pairs)) {
return null;
}
$validPairs = Arr::where($pairs, fn (mixed $pair): bool => is_array($pair) && isset($pair[0], $pair[1]));
$variables = Arr::pluck($validPairs, 1, 0);
return $variables !== [] ? $variables : null;
}
/**
* Get the first palette ID from model data.
*/

View file

@ -27,6 +27,7 @@ final class DeviceModel extends Model
'offset_x' => 'integer',
'offset_y' => 'integer',
'published_at' => 'datetime',
'css_variables' => 'array',
];
public function getColorDepthAttribute(): ?string

View file

@ -140,8 +140,9 @@ class PlaylistItem extends Model
if (! $this->isMashup()) {
return view('trmnl-layouts.single', [
'colorDepth' => $device?->colorDepth(),
'deviceVariant' => $device?->deviceVariant() ?? 'og',
'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
'scaleLevel' => $device?->scaleLevel(),
'cssVariables' => $device?->deviceModel?->css_variables,
'slot' => $this->plugin instanceof Plugin
? $this->plugin->render('full', false, $device)
: throw new Exception('Invalid plugin instance'),
@ -162,8 +163,9 @@ class PlaylistItem extends Model
return view('trmnl-layouts.mashup', [
'colorDepth' => $device?->colorDepth(),
'deviceVariant' => $device?->deviceVariant() ?? 'og',
'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
'scaleLevel' => $device?->scaleLevel(),
'cssVariables' => $device?->deviceModel?->css_variables,
'mashupLayout' => $this->getMashupLayoutType(),
'slot' => implode('', $pluginMarkups),
])->render();

View file

@ -584,10 +584,11 @@ class Plugin extends Model
if ($size === 'full') {
return view('trmnl-layouts.single', [
'colorDepth' => $device?->colorDepth(),
'deviceVariant' => $device?->deviceVariant() ?? 'og',
'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
'noBleed' => $this->no_bleed,
'darkMode' => $this->dark_mode,
'scaleLevel' => $device?->scaleLevel(),
'cssVariables' => $device?->deviceModel?->css_variables,
'slot' => $renderedContent,
])->render();
}
@ -595,9 +596,10 @@ class Plugin extends Model
return view('trmnl-layouts.mashup', [
'mashupLayout' => $this->getPreviewMashupLayoutForSize($size),
'colorDepth' => $device?->colorDepth(),
'deviceVariant' => $device?->deviceVariant() ?? 'og',
'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
'darkMode' => $this->dark_mode,
'scaleLevel' => $device?->scaleLevel(),
'cssVariables' => $device?->deviceModel?->css_variables,
'slot' => $renderedContent,
])->render();
@ -617,10 +619,11 @@ class Plugin extends Model
if ($size === 'full') {
return view('trmnl-layouts.single', [
'colorDepth' => $device?->colorDepth(),
'deviceVariant' => $device?->deviceVariant() ?? 'og',
'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
'noBleed' => $this->no_bleed,
'darkMode' => $this->dark_mode,
'scaleLevel' => $device?->scaleLevel(),
'cssVariables' => $device?->deviceModel?->css_variables,
'slot' => $renderedView,
])->render();
}
@ -628,9 +631,10 @@ class Plugin extends Model
return view('trmnl-layouts.mashup', [
'mashupLayout' => $this->getPreviewMashupLayoutForSize($size),
'colorDepth' => $device?->colorDepth(),
'deviceVariant' => $device?->deviceVariant() ?? 'og',
'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
'darkMode' => $this->dark_mode,
'scaleLevel' => $device?->scaleLevel(),
'cssVariables' => $device?->deviceModel?->css_variables,
'slot' => $renderedView,
])->render();
}

View file

@ -514,7 +514,7 @@ class ImageGenerationService
};
// Determine device properties from DeviceModel or device settings
$deviceVariant = $device->deviceVariant();
$deviceVariant = $device->deviceModel?->css_name ?? $device->deviceVariant();
$deviceOrientation = $device->rotate > 0 ? 'portrait' : 'landscape';
$colorDepth = $device->colorDepth() ?? '1bit';
$scaleLevel = $device->scaleLevel();
@ -528,6 +528,7 @@ class ImageGenerationService
'deviceOrientation' => $deviceOrientation,
'colorDepth' => $colorDepth,
'scaleLevel' => $scaleLevel,
'cssVariables' => $device->deviceModel?->css_variables,
];
// Add plugin name for error screens

View file

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('device_models', function (Blueprint $table) {
$table->string('css_name')->nullable()->after('kind');
$table->json('css_variables')->nullable()->after('css_name');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('device_models', function (Blueprint $table) {
$table->dropColumn(['css_name', 'css_variables']);
});
}
};

View file

@ -0,0 +1,160 @@
<?php
use App\Models\DeviceModel;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* CSS name and variables for device models created by seed_device_models (og_png until inky_impression_13_3).
*
* @var array<string, array{css_name: string, css_variables: array<string, string>}>
*/
private const SEEDED_CSS = [
'og_png' => [
'css_name' => 'og_png',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '480px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'og_plus' => [
'css_name' => 'ogv2',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '480px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'amazon_kindle_2024' => [
'css_name' => 'amazon_kindle_2024',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '480px',
'--ui-scale' => '0.8',
'--gap-scale' => '1.0',
],
],
'amazon_kindle_paperwhite_6th_gen' => [
'css_name' => 'amazon_kindle_paperwhite_6th_gen',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '600px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'amazon_kindle_paperwhite_7th_gen' => [
'css_name' => 'amazon_kindle_paperwhite_7th_gen',
'css_variables' => [
'--screen-w' => '905px',
'--screen-h' => '670px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'inkplate_10' => [
'css_name' => 'inkplate_10',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '547px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'amazon_kindle_7' => [
'css_name' => 'amazon_kindle_7',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '600px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'inky_impression_7_3' => [
'css_name' => 'inky_impression_7_3',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '480px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'kobo_libra_2' => [
'css_name' => 'kobo_libra_2',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '602px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'amazon_kindle_oasis_2' => [
'css_name' => 'amazon_kindle_oasis_2',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '602px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'kobo_aura_one' => [
'css_name' => 'kobo_aura_one',
'css_variables' => [
'--screen-w' => '1040px',
'--screen-h' => '780px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'kobo_aura_hd' => [
'css_name' => 'kobo_aura_hd',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '600px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'inky_impression_13_3' => [
'css_name' => 'inky_impression_13_3',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '600px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
];
/**
* Run the migrations.
*/
public function up(): void
{
foreach (self::SEEDED_CSS as $name => $payload) {
DeviceModel::query()
->where('name', $name)
->update([
'css_name' => $payload['css_name'],
'css_variables' => $payload['css_variables'],
]);
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
DeviceModel::query()
->whereIn('name', array_keys(self::SEEDED_CSS))
->update([
'css_name' => null,
'css_variables' => null,
]);
}
};

View file

@ -5,12 +5,14 @@
'deviceOrientation' => null,
'colorDepth' => '1bit',
'scaleLevel' => null,
'cssVariables' => null,
'pluginName' => 'Recipe',
])
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
scale-level="{{$scaleLevel}}">
scale-level="{{$scaleLevel}}"
:css-variables="$cssVariables">
<x-trmnl::view>
<x-trmnl::layout>
<x-trmnl::richtext gapSize="large" align="center">

View file

@ -5,11 +5,13 @@
'deviceOrientation' => null,
'colorDepth' => '1bit',
'scaleLevel' => null,
'cssVariables' => null,
])
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
scale-level="{{$scaleLevel}}">
scale-level="{{$scaleLevel}}"
:css-variables="$cssVariables">
<x-trmnl::view>
<x-trmnl::layout>
<x-trmnl::richtext gapSize="large" align="center">

View file

@ -5,11 +5,13 @@
'deviceOrientation' => null,
'colorDepth' => '1bit',
'scaleLevel' => null,
'cssVariables' => null,
])
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
scale-level="{{$scaleLevel}}">
scale-level="{{$scaleLevel}}"
:css-variables="$cssVariables">
<x-trmnl::view>
<x-trmnl::layout>
<x-trmnl::richtext gapSize="large" align="center">

View file

@ -39,6 +39,8 @@ new class extends Component
public $palette_id;
public $css_name;
protected $rules = [
'name' => 'required|string|max:255|unique:device_models,name',
'label' => 'required|string|max:255',
@ -102,10 +104,11 @@ new class extends Component
$this->offset_y = $deviceModel->offset_y;
$this->published_at = $deviceModel->published_at?->format('Y-m-d\TH:i');
$this->palette_id = $deviceModel->palette_id;
$this->css_name = $deviceModel->css_name;
} else {
$this->editingDeviceModelId = null;
$this->viewingDeviceModelId = null;
$this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'palette_id']);
$this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'palette_id', 'css_name']);
$this->mime_type = 'image/png';
$this->scale_factor = 1.0;
$this->rotation = 0;
@ -131,6 +134,7 @@ new class extends Component
'offset_y' => 'required|integer',
'published_at' => 'nullable|date',
'palette_id' => 'nullable|exists:device_palettes,id',
'css_name' => 'nullable|string|max:255',
];
if ($this->editingDeviceModelId) {
@ -158,6 +162,7 @@ new class extends Component
'offset_y' => $this->offset_y,
'published_at' => $this->published_at,
'palette_id' => $this->palette_id ?: null,
'css_name' => $this->css_name ?: null,
]);
$message = 'Device model updated successfully.';
} else {
@ -176,12 +181,13 @@ new class extends Component
'offset_y' => $this->offset_y,
'published_at' => $this->published_at,
'palette_id' => $this->palette_id ?: null,
'css_name' => $this->css_name ?: null,
'source' => 'manual',
]);
$message = 'Device model created successfully.';
}
$this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'palette_id', 'editingDeviceModelId', 'viewingDeviceModelId']);
$this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'palette_id', 'css_name', 'editingDeviceModelId', 'viewingDeviceModelId']);
Flux::modal('device-model-modal')->close();
$this->deviceModels = DeviceModel::all();
@ -217,6 +223,7 @@ new class extends Component
$this->offset_y = $deviceModel->offset_y;
$this->published_at = $deviceModel->published_at?->format('Y-m-d\TH:i');
$this->palette_id = $deviceModel->palette_id;
$this->css_name = $deviceModel->css_name;
$this->js('Flux.modal("device-model-modal").show()');
}
@ -344,6 +351,11 @@ new class extends Component
</flux:select>
</div>
<div class="mb-4">
<flux:input label="CSS Model Identifier" wire:model="css_name" id="css_name" class="block mt-1 w-full" type="text"
name="css_name" :disabled="(bool) $viewingDeviceModelId"/>
</div>
@if (!$viewingDeviceModelId)
<div class="flex">
<flux:spacer/>

View file

@ -6,18 +6,22 @@
'deviceOrientation' => null,
'colorDepth' => '1bit',
'scaleLevel' => null,
'cssVariables' => null,
])
@if(config('app.puppeteer_window_size_strategy') === 'v2')
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
scale-level="{{$scaleLevel}}">
scale-level="{{$scaleLevel}}"
:css-variables="$cssVariables">
<x-trmnl::mashup mashup-layout="{{ $mashupLayout }}">
{!! $slot !!}
</x-trmnl::mashup>
</x-trmnl::screen>
@else
<x-trmnl::screen colorDepth="{{$colorDepth}}">
<x-trmnl::screen colorDepth="{{$colorDepth}}" device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
scale-level="{{$scaleLevel}}"
:css-variables="$cssVariables">
<x-trmnl::mashup mashup-layout="{{ $mashupLayout }}">
{!! $slot !!}
</x-trmnl::mashup>

View file

@ -5,16 +5,21 @@
'deviceOrientation' => null,
'colorDepth' => '1bit',
'scaleLevel' => null,
'cssVariables' => null,
])
@if(config('app.puppeteer_window_size_strategy') === 'v2')
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
scale-level="{{$scaleLevel}}">
scale-level="{{$scaleLevel}}"
:css-variables="$cssVariables">
{!! $slot !!}
</x-trmnl::screen>
@else
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}">
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
scale-level="{{$scaleLevel}}"
:css-variables="$cssVariables">
{!! $slot !!}
</x-trmnl::screen>
@endif

View file

@ -1,19 +1,13 @@
@props([
'noBleed' => false,
'darkMode' => false,
'deviceVariant' => 'og',
'deviceVariant' => 'ogv2',
'deviceOrientation' => null,
'colorDepth' => '1bit',
'scaleLevel' => null,
'cssVariables' => null,
])
@php
// HOTFIX Github Issue https://github.com/usetrmnl/byos_laravel/issues/190
if ($colorDepth == '2bit'){
$deviceVariant = 'ogv2';
}
@endphp
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@ -33,6 +27,15 @@ if ($colorDepth == '2bit'){
<script src="{{ config('services.trmnl.base_url') }}/js/{{ config('trmnl-blade.framework_js_version') ?? config('trmnl-blade.framework_version', '2.1.0') }}/plugins.js"></script>
@endif
<title>{{ $title ?? config('app.name') }}</title>
@if(config('app.puppeteer_window_size_strategy') === 'v2' && !empty($cssVariables) && is_array($cssVariables))
<style>
:root {
@foreach($cssVariables as $name => $value)
{{ $name }}: {{ $value }};
@endforeach
}
</style>
@endif
</head>
<body class="environment trmnl">
<div class="screen {{ $noBleed ? 'screen--no-bleed' : '' }} {{ $darkMode ? 'dark-mode' : '' }} {{ $deviceVariant ? 'screen--' . $deviceVariant : '' }} {{ $deviceOrientation ? 'screen--' . $deviceOrientation : '' }} {{ $colorDepth ? 'screen--' . $colorDepth : '' }} {{ $scaleLevel ? 'screen--scale-' . $scaleLevel : '' }}">