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" > -
- -
-
-
--
-
- --:-- - -- -
-
- -- -
- weather icon - --° -
-
-
-
+
+

--

+
+ --:-- + +
+
+ -- + + + --° + +
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