diff --git a/index.html b/index.html
index 12e0001..bb90b18 100644
--- a/index.html
+++ b/index.html
@@ -12,29 +12,19 @@
reference-height="1080"
orientation="auto"
>
-
-
-
-
- --
-
- --:--
- --
-
-
-
--
-
-

-
--°
-
-
-
-
+
diff --git a/screenly.yml b/screenly.yml
index a4cac2a..1af27d9 100644
--- a/screenly.yml
+++ b/screenly.yml
@@ -43,6 +43,23 @@ settings:
help_text: |
Override the default timezone with a supported timezone identifier (e.g., Europe/London, America/New_York). Defaults to the system timezone if left blank.
You can find the available timezones here: https://momentjs.com/timezone/
+ style:
+ type: string
+ default_value: traditional
+ title: Style
+ optional: true
+ help_text:
+ properties:
+ help_text: Select the visual style for the clock.
+ options:
+ - label: Traditional
+ value: traditional
+ - label: Modern
+ value: modern
+ - label: Minimal
+ value: minimal
+ type: select
+ schema_version: 1
tag_manager_id:
type: string
default_value: GTM-P98SPZ9Z
diff --git a/screenly_qc.yml b/screenly_qc.yml
index 28dded1..00a6039 100644
--- a/screenly_qc.yml
+++ b/screenly_qc.yml
@@ -43,6 +43,23 @@ settings:
help_text: |
Override the default timezone with a supported timezone identifier (e.g., Europe/London, America/New_York). Defaults to the system timezone if left blank.
You can find the available timezones here: https://momentjs.com/timezone/
+ style:
+ type: string
+ default_value: traditional
+ title: Style
+ optional: true
+ help_text:
+ properties:
+ help_text: Select the visual style for the clock.
+ options:
+ - label: Traditional
+ value: traditional
+ - label: Modern
+ value: modern
+ - label: Minimal
+ value: minimal
+ type: select
+ schema_version: 1
tag_manager_id:
type: string
default_value: GTM-P98SPZ9Z
diff --git a/screenshots/1080x1920.webp b/screenshots/1080x1920.webp
index 21a5e68..5cfe2cd 100644
Binary files a/screenshots/1080x1920.webp and b/screenshots/1080x1920.webp differ
diff --git a/screenshots/1280x720.webp b/screenshots/1280x720.webp
index 3d0df4a..e9aa943 100644
Binary files a/screenshots/1280x720.webp and b/screenshots/1280x720.webp differ
diff --git a/screenshots/1920x1080.webp b/screenshots/1920x1080.webp
index 1b2de67..b88f4a0 100644
Binary files a/screenshots/1920x1080.webp and b/screenshots/1920x1080.webp differ
diff --git a/screenshots/2160x3840.webp b/screenshots/2160x3840.webp
index 2172827..c7be4cc 100644
Binary files a/screenshots/2160x3840.webp and b/screenshots/2160x3840.webp differ
diff --git a/screenshots/2160x4096.webp b/screenshots/2160x4096.webp
index 0c97b29..c5e7254 100644
Binary files a/screenshots/2160x4096.webp and b/screenshots/2160x4096.webp differ
diff --git a/screenshots/3840x2160.webp b/screenshots/3840x2160.webp
index 4436e27..55ecb92 100644
Binary files a/screenshots/3840x2160.webp and b/screenshots/3840x2160.webp differ
diff --git a/screenshots/4096x2160.webp b/screenshots/4096x2160.webp
index bf558ad..c8e74a9 100644
Binary files a/screenshots/4096x2160.webp and b/screenshots/4096x2160.webp differ
diff --git a/screenshots/480x800.webp b/screenshots/480x800.webp
index 7d3f73a..64c5edd 100644
Binary files a/screenshots/480x800.webp and b/screenshots/480x800.webp differ
diff --git a/screenshots/720x1280.webp b/screenshots/720x1280.webp
index 83a3fc1..ff0edcc 100644
Binary files a/screenshots/720x1280.webp and b/screenshots/720x1280.webp differ
diff --git a/screenshots/800x480.webp b/screenshots/800x480.webp
index ffffe32..bfeb497 100644
Binary files a/screenshots/800x480.webp and b/screenshots/800x480.webp differ
diff --git a/src/css/style.css b/src/css/style.css
index 952eb6e..a401284 100644
--- a/src/css/style.css
+++ b/src/css/style.css
@@ -1,43 +1,76 @@
-/* Import Screenly Design System */
@import '@screenly/edge-apps/styles';
-/* App-specific styles */
-
-@font-face {
- font-family: 'Kelly Slab';
- src: url('/static/fonts/KellySlab-Regular.ttf') format('truetype');
- font-weight: 400;
- font-style: normal;
- font-display: swap;
-}
-
-* {
- box-sizing: border-box;
+:root {
+ --clock-accent: var(--theme-color-primary, #972eff);
+ --clock-canvas: #08080d;
+ --clock-ink: #fff;
+ --clock-ink-dim: rgb(255 255 255 / 55%);
+ --clock-rule: rgb(255 255 255 / 12%);
}
body {
- margin: 0;
- padding: 0;
- overflow: hidden;
- color: white;
- font-family: 'Inter', system-ui, sans-serif;
- background: url('/static/images/bg.webp') no-repeat center center;
- background-size: cover;
- text-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
-}
+ --clock-gap: 2.5rem;
+ --clock-place-size: 2.5rem;
+ --clock-place-tracking: 0.15em;
+ --clock-place-transform: uppercase;
+ --clock-place-weight: 500;
+ --clock-time-size: 30rem;
+ --clock-time-tracking: -0.03em;
+ --clock-time-weight: 500;
+ --clock-period-size: 5rem;
+ --clock-meta-size: 2.25rem;
+ --clock-wash: radial-gradient(
+ ellipse 75% 50% at 50% 0%,
+ color-mix(in srgb, var(--clock-accent) 45%, transparent),
+ transparent
+ );
-body::before {
- content: '';
- position: fixed;
- inset: 0;
- z-index: var(--z-index-backdrop);
- pointer-events: none;
- background: rgba(0, 0, 0, 0.2);
- backdrop-filter: blur(7px);
+ color: var(--clock-ink);
+ background-color: var(--clock-canvas);
+ background-image: var(--clock-wash);
+}
+
+body.is-light-brand {
+ --clock-canvas: #f6f6f8;
+ --clock-ink: #0a0a0f;
+ --clock-ink-dim: rgb(10 10 15 / 58%);
+ --clock-rule: rgb(10 10 15 / 14%);
+}
+
+body.style-modern {
+ --clock-gap: 1.5rem;
+ --clock-place-size: 2.75rem;
+ --clock-place-tracking: -0.01em;
+ --clock-place-transform: none;
+ --clock-place-weight: 600;
+ --clock-time-size: 33rem;
+ --clock-time-tracking: -0.055em;
+ --clock-time-weight: 600;
+ --clock-period-size: 5.5rem;
+ --clock-meta-size: 2rem;
+ --clock-wash: radial-gradient(
+ ellipse 70% 65% at 12% 0%,
+ color-mix(in srgb, var(--clock-accent) 55%, transparent),
+ transparent
+ );
}
-.text-shadow {
- text-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
+body.style-minimal {
+ --clock-gap: 4.5rem;
+ --clock-place-size: 1.75rem;
+ --clock-place-tracking: 0.45em;
+ --clock-place-transform: uppercase;
+ --clock-place-weight: 400;
+ --clock-time-size: 23rem;
+ --clock-time-tracking: 0.01em;
+ --clock-time-weight: 400;
+ --clock-period-size: 3.25rem;
+ --clock-meta-size: 1.75rem;
+ --clock-wash: radial-gradient(
+ ellipse 90% 60% at 50% 0%,
+ color-mix(in srgb, var(--clock-accent) 20%, transparent),
+ transparent
+ );
}
auto-scaler {
@@ -45,118 +78,142 @@ auto-scaler {
z-index: var(--z-index-content);
}
-#app {
+.clock {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--clock-gap);
width: 100%;
height: 100%;
+ text-align: center;
}
-.content {
- margin-top: 160px;
- margin-left: 300px;
+.clock-place {
+ margin: 0;
+ color: var(--clock-ink-dim);
+ font-size: var(--clock-place-size);
+ font-weight: var(--clock-place-weight);
+ letter-spacing: var(--clock-place-tracking);
+ text-transform: var(--clock-place-transform);
}
-.current {
+.clock-time {
display: flex;
- flex-direction: column;
+ align-items: baseline;
+ justify-content: center;
+ gap: 1.5rem;
}
-.city {
- font-size: 32px;
- text-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
+.clock-time-value {
+ font-size: var(--clock-time-size);
+ font-weight: var(--clock-time-weight);
+ line-height: 0.82;
+ letter-spacing: var(--clock-time-tracking);
+ font-variant-numeric: tabular-nums;
}
-.time-wrapper {
- display: flex;
- align-items: baseline;
- gap: 0.5rem;
+.clock-time-period {
+ color: var(--clock-ink-dim);
+ font-size: var(--clock-period-size);
+ font-weight: var(--clock-time-weight);
}
-.time {
- font-family: 'Kelly Slab', serif;
- font-size: 160px;
- font-weight: 400;
- line-height: 1;
- letter-spacing: -5px;
- margin: 1rem 0;
- text-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
+.clock-meta {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 1.75rem;
+ color: var(--clock-ink-dim);
+ font-size: var(--clock-meta-size);
}
-.clock-period {
- font-size: 2.25rem;
- opacity: 0.9;
+.clock-weather {
+ display: none;
+ align-items: center;
+ gap: 0.75rem;
+ padding-left: 1.75rem;
+ border-left: 0.0625rem solid var(--clock-rule);
}
-.status-row {
+.clock-weather.is-visible {
display: inline-flex;
- align-items: center;
- gap: 1rem;
- padding: 0.625rem 1.25rem;
- border-radius: 1rem;
- border: 1px solid rgba(255, 255, 255, 0.06);
- background: linear-gradient(
- 135deg,
- rgba(255, 255, 255, 0.06) 0%,
- rgba(255, 255, 255, 0.01) 100%
- );
- box-shadow:
- 0 13.438px 40.315px 0 rgba(0, 0, 0, 0.15),
- 0 1.68px 0 0 rgba(255, 255, 255, 0.1) inset,
- 0 -1.68px 0 0 rgba(255, 255, 255, 0.03) inset;
- width: fit-content;
}
-.date {
- font-size: 1rem;
- text-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
+.clock-weather-icon {
+ width: 2.5rem;
+ height: 2.5rem;
}
-.temperature-wrapper {
- display: flex;
- align-items: center;
- gap: 0.375rem;
+.style-modern .clock {
+ align-items: flex-start;
+ padding: 0 5rem;
+ text-align: left;
+}
+
+.style-modern .clock-place {
+ color: var(--clock-ink);
+}
+
+.style-modern .clock-time,
+.style-modern .clock-meta {
+ justify-content: flex-start;
}
-.temperature {
- font-size: 1rem;
- text-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
+.style-minimal .clock-weather {
+ padding-left: 0;
+ border-left: 0;
}
-.temperature-icon {
- width: 1rem;
- height: 1rem;
+.style-minimal .clock-meta {
+ gap: 2.5rem;
+}
+
+.style-minimal .clock-weather-icon {
+ width: 1.75rem;
+ height: 1.75rem;
}
@media (orientation: portrait) {
- #app {
- display: flex;
- flex-direction: column;
- height: 100%;
+ body {
+ --clock-gap: 4.5rem;
+ --clock-place-size: 3rem;
+ --clock-time-size: 19rem;
+ --clock-period-size: 4rem;
+ --clock-meta-size: 2.75rem;
}
- main.content {
- margin: 0;
- display: flex;
- flex-direction: column;
- justify-content: center;
- align-items: flex-start;
- flex: 1;
- height: 100%;
- padding-left: 60px;
+ body.style-modern {
+ --clock-gap: 2.5rem;
+ --clock-place-size: 3rem;
+ --clock-time-size: 20rem;
+ --clock-period-size: 4.5rem;
+ --clock-meta-size: 2.5rem;
}
- .current {
- margin-left: 60px;
+ body.style-minimal {
+ --clock-gap: 6rem;
+ --clock-place-size: 2rem;
+ --clock-time-size: 15rem;
+ --clock-period-size: 2.75rem;
+ --clock-meta-size: 2rem;
}
- .city {
- font-size: 40px;
+ .clock-meta {
+ flex-direction: column;
+ gap: 2rem;
+ }
+
+ .clock-weather {
+ padding-left: 0;
+ border-left: 0;
}
- .time {
- font-size: 180px;
+ .style-modern .clock {
+ padding: 0 3.5rem;
}
- .status-row {
- font-size: 24px;
+ .style-modern .clock-meta {
+ align-items: flex-start;
}
}
diff --git a/src/main.ts b/src/main.ts
index 3c8ea2f..9e0d987 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -4,47 +4,30 @@ import {
getMetadata,
getTimeZone,
getLocale,
+ isLightColor,
+ setupTheme,
signalReady,
getCityInfo,
} from '@screenly/edge-apps'
-// Import components to register them as custom elements
-// This registers
, , , and
+// Side-effect import: registers as a custom element
import '@screenly/edge-apps/components'
import { getWeatherData } from './weather'
import { getTimeData } from './time'
+import { getClockStyle } from './style'
-// Note: Auto-scaling and dev tools are now handled declaratively in index.html
-// via and web components
+const WEATHER_REFRESH_MS = 15 * 60 * 1000
-// DOM elements (will be initialized in DOMContentLoaded)
let locationEl: Element | null
let timeEl: Element | null
let periodEl: Element | null
let dateEl: Element | null
let temperatureEl: Element | null
let weatherIconEl: HTMLImageElement | null
-let temperatureWrapperEl: Element | null
+let weatherEl: Element | null
-// State
let timezone: string = 'UTC'
let locale: string = 'en'
-let locationName: string = 'Unknown Location'
-// Hide temperature section helper
-function hideTemperatureSection() {
- if (temperatureWrapperEl) {
- ;(temperatureWrapperEl as HTMLElement).style.display = 'none'
- }
-}
-
-// Show temperature section helper
-function showTemperatureSection() {
- if (temperatureWrapperEl) {
- ;(temperatureWrapperEl as HTMLElement).style.display = ''
- }
-}
-
-// Update weather display
async function updateWeatherDisplay(
latitude: number,
longitude: number,
@@ -53,12 +36,8 @@ async function updateWeatherDisplay(
) {
const weatherData = await getWeatherData(latitude, longitude, tz, countryCode)
- if (!weatherData) {
- hideTemperatureSection()
- return
- }
-
- showTemperatureSection()
+ weatherEl?.classList.toggle('is-visible', Boolean(weatherData))
+ if (!weatherData) return
if (temperatureEl) {
temperatureEl.textContent = weatherData.displayText
@@ -70,7 +49,6 @@ async function updateWeatherDisplay(
}
}
-// Update time display
function updateTime() {
const now = new Date()
const data = getTimeData(now, locale, timezone)
@@ -88,55 +66,43 @@ function updateTime() {
}
}
-// Initialize app
document.addEventListener('DOMContentLoaded', async () => {
try {
- // Query DOM elements now that DOM is ready
locationEl = document.querySelector('[data-location]')
timeEl = document.querySelector('[data-time]')
periodEl = document.querySelector('[data-period]')
dateEl = document.querySelector('[data-date]')
temperatureEl = document.querySelector('[data-temperature]')
- weatherIconEl = document.querySelector(
- '[data-weather-icon]',
- ) as HTMLImageElement | null
- temperatureWrapperEl = document.querySelector('.temperature-wrapper')
+ weatherIconEl = document.querySelector('[data-weather-icon]')
+ weatherEl = document.querySelector('[data-weather]')
+
+ const { primary } = setupTheme()
+ document.body.classList.toggle('is-light-brand', isLightColor(primary))
+ document.body.classList.add(`style-${getClockStyle()}`)
- // Get metadata (includes coordinates)
const metadata = getMetadata()
const [latitude, longitude] = metadata.coordinates
- // Get timezone and locale from coordinates
timezone = await getTimeZone()
locale = await getLocale()
- // Get location info (includes city name and country code)
const { cityName, countryCode } = await getCityInfo(latitude, longitude)
- locationName = cityName
if (locationEl) {
- locationEl.textContent = locationName
+ locationEl.textContent = cityName
}
- // Get weather data (optional)
await updateWeatherDisplay(latitude, longitude, timezone, countryCode)
- // Update time immediately
updateTime()
-
- // Update time every second
setInterval(updateTime, 1000)
-
- // Refresh weather every 15 minutes
- setInterval(
- () => {
- updateWeatherDisplay(latitude, longitude, timezone, countryCode)
- },
- 15 * 60 * 1000,
- )
+ setInterval(() => {
+ updateWeatherDisplay(latitude, longitude, timezone, countryCode).catch(
+ (error) => console.error('Failed to refresh weather:', error),
+ )
+ }, WEATHER_REFRESH_MS)
} catch (error) {
console.error('Failed to initialize app:', error)
}
- // Signal that the app is ready to be shown
signalReady()
})
diff --git a/src/style.test.ts b/src/style.test.ts
new file mode 100644
index 0000000..f0fd99d
--- /dev/null
+++ b/src/style.test.ts
@@ -0,0 +1,23 @@
+import '@screenly/edge-apps/test'
+import { describe, test, expect } from 'bun:test'
+import { resolveStyle } from './style'
+
+describe('resolveStyle', () => {
+ test('should accept every style the manifest offers', () => {
+ expect(resolveStyle('traditional')).toBe('traditional')
+ expect(resolveStyle('modern')).toBe('modern')
+ expect(resolveStyle('minimal')).toBe('minimal')
+ })
+
+ test('should tolerate casing and whitespace from the dashboard', () => {
+ expect(resolveStyle(' Modern ')).toBe('modern')
+ expect(resolveStyle('MINIMAL')).toBe('minimal')
+ })
+
+ test('should fall back to traditional for unknown values', () => {
+ expect(resolveStyle('brutalist')).toBe('traditional')
+ expect(resolveStyle('')).toBe('traditional')
+ expect(resolveStyle(undefined)).toBe('traditional')
+ expect(resolveStyle(null)).toBe('traditional')
+ })
+})
diff --git a/src/style.ts b/src/style.ts
new file mode 100644
index 0000000..cbf9cd0
--- /dev/null
+++ b/src/style.ts
@@ -0,0 +1,23 @@
+import { getSettingWithDefault } from '@screenly/edge-apps'
+
+export type ClockStyle = 'traditional' | 'modern' | 'minimal'
+
+export const DEFAULT_STYLE: ClockStyle = 'traditional'
+
+const STYLES: readonly string[] = ['traditional', 'modern', 'minimal']
+
+/**
+ * Normalise a style setting, falling back to the default for anything the app
+ * does not recognise. Settings are free text at the API level, so an instance
+ * can hold a value this build has never heard of.
+ */
+export function resolveStyle(value: string | undefined | null): ClockStyle {
+ const normalised = value?.trim().toLowerCase()
+ if (!normalised || !STYLES.includes(normalised)) return DEFAULT_STYLE
+
+ return normalised as ClockStyle
+}
+
+export function getClockStyle(): ClockStyle {
+ return resolveStyle(getSettingWithDefault('style', DEFAULT_STYLE))
+}
diff --git a/static/fonts/KellySlab-Regular.ttf b/static/fonts/KellySlab-Regular.ttf
deleted file mode 100644
index 416c690..0000000
Binary files a/static/fonts/KellySlab-Regular.ttf and /dev/null differ
diff --git a/static/images/bg.webp b/static/images/bg.webp
deleted file mode 100644
index ece5f3c..0000000
Binary files a/static/images/bg.webp and /dev/null differ