A skeuomorphic attempt at recreating a solar calculator on the web with the Window Controls Overlay API and the Ambient Light Sensor API.
The challenge
I'm a kid of the 1980s. A thing that was all the rage back when I was in high school were solar calculators. We were all given a TI-30X SOLAR by the school, and I have fond memories of us benchmarking our calculators against each other by calculating the factorial of 69, the highest number the TI-30X could handle. (The speed variance was very measurable, I have still no idea why.)
Now, almost 28 years later, I thought it would be a fun Designcember challenge to recreate the calculator in HTML, CSS, and JavaScript. Being not much of a designer, I did not start from scratch, but with a CodePen by Sassja Ceballos.
Make it installable
While not a bad start, I decided to pump it up for full skeuomorphic awesomeness. The first step was to make it a PWA so it could be installed. I maintain a baseline PWA template on Glitch that I remix whenever I need a quick demo. Its service worker will not win you any coding award and it is definitely not production-ready, but it is sufficient to trigger Chromium's mini infobar so the app can be installed.
self.addEventListener('install', (event) => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
self.clients.claim();
event.waitUntil(
(async () => {
if ('navigationPreload' in self.registration) {
await self.registration.navigationPreload.enable();
}
})(),
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
(async () => {
try {
const response = await event.preloadResponse;
if (response) {
return response;
}
return fetch(event.request);
} catch {
return new Response('Offline');
}
})(),
);
});
Blending with mobile
Now that the app is installable, the next step is to make it blend in with the operating system apps as much as possible. On
mobile, I can do this by setting the display mode to fullscreen
in the Web App Manifest.
{
"display": "fullscreen"
}
On devices with a camera hole or notch, tweaking the viewport so that the content covers the whole screen makes the app look gorgeous.
<meta name="viewport" content="initial-scale=1, viewport-fit=cover" />
Blending with desktop
On desktop, there is a cool feature I can use:
Window Controls Overlay, which allows me to put content in the title
bar of the app window. The first step is to override the display mode fallback sequence so it tries
to use window-controls-overlay
first when it is available.
{
"display_override": ["window-controls-overlay"]
}
This makes the title bar effectively go away and the content moves up into the title bar area as if
the title bar were not there. My idea is to move the skeuomorphic solar cell up into the title bar
and the rest of the calculator UI down accordingly, which I can do with some CSS that uses
the titlebar-area-*
environment variables. You will notice that all the selectors carry a wco
class, which will be relevant a couple of paragraphs down.
#calc_solar_cell.wco {
position: fixed;
left: calc(0.25rem + env(titlebar-area-x, 0));
top: calc(0.75rem + env(titlebar-area-y, 0));
width: calc(env(titlebar-area-width, 100%) - 0.5rem);
height: calc(env(titlebar-area-height, 33px) - 0.5rem);
}
#calc_display_surface.wco {
margin-top: calc(env(titlebar-area-height, 33px) - 0.5rem);
}
Next, I need to decide which elements to make draggable, since the title bar that I would usually
use for dragging is not available. In the style of a classic widget, I can even make the
entire calculator draggable by applying (-webkit-)app-region: drag
, apart from the buttons, which
get (-webkit-)app-region: no-drag
so they cannot be used to drag.
#calc_inside.wco,
#calc_solar_cell.wco {
-webkit-app-region: drag;
app-region: drag;
}
button {
-webkit-app-region: no-drag;
app-region: no-drag;
}
The final step is to make the app reactive to window controls overlay changes. In a true progressive enhancement approach, I only load the code for this feature when the browser supports it.
if ('windowControlsOverlay' in navigator) {
import('/wco.js');
}
Whenever the window controls overlay geometry changes, I modify the app to make
it look as natural as possible. It is a good idea to debounce this event, since it can be triggered
frequently when the user resizes the window. Namely, I apply the wco
class to some elements, so my
CSS from above kicks in, and I also change the theme color. I can detect if the window controls
overlay is visible by checking the navigator.windowControlsOverlay.visible
property.
const meta = document.querySelector('meta[name="theme-color"]');
const nodes = document.querySelectorAll(
'#calc_display_surface, #calc_solar_cell, #calc_outside, #calc_inside',
);
const toggleWCO = () => {
if (!navigator.windowControlsOverlay.visible) {
meta.content = '';
} else {
meta.content = '#385975';
}
nodes.forEach((node) => {
node.classList.toggle('wco', navigator.windowControlsOverlay.visible);
});
};
const debounce = (func, wait) => {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
};
navigator.windowControlsOverlay.ongeometrychange = debounce((e) => {
toggleWCO();
}, 250);
toggleWCO();
Now with all this in place, I get a calculator widget that feels almost like the classic Winamp with one of the oldschool Winamp themes. I can now freely place the calculator on my desktop and activate the window controls feature by clicking the chevron in the upper right corner.
An actually working solar cell
For the ultimate geekery, I of course needed to make the solar cell actually work. The calculator
should only be functioning if there is enough light. The way I modeled this is through setting the
CSS opacity
of the digits on the display via a CSS variable --opacity
that I control via
JavaScript.
:root {
--opacity: 0.75;
}
#calc_expression,
#calc_result {
opacity: var(--opacity);
}
To detect if enough light is available for the calculator to work, I use the
AmbientLightSensor
API. For
this API to be available, I needed to set the #enable-generic-sensor-extra-classes
flag in
about:flags
and request the 'ambient-light-sensor'
permission. As before, I use progressive
enhancement to only load the relevant code when the API is supported.
if ('AmbientLightSensor' in window) {
import('/als.js');
}
The sensor returns the ambient light in lux units whenever a
new reading is available. Based on a
table of values of typical light situations, I came
up with a very simple formula to convert the lux value to a value between 0 and 1 that I
programmatically assign to the --opacity
variable.
const luxToOpacity = (lux) => {
if (lux > 250) {
return 1;
}
return lux / 250;
};
const sensor = new window.AmbientLightSensor();
sensor.onreading = () => {
console.log('Current light level:', sensor.illuminance);
document.documentElement.style.setProperty(
'--opacity',
luxToOpacity(sensor.illuminance),
);
};
sensor.onerror = (event) => {
console.log(event.error.name, event.error.message);
};
(async () => {
const {state} = await navigator.permissions.query({
name: 'ambient-light-sensor',
});
if (state === 'granted') {
sensor.start();
}
})();
In the video below you can see how the calculator starts working once I turn the room light up enough. And there you have it: a skeuomorphic solar calculator that actually works. My good old time-tested TI-30X SOLAR has come a long way indeed.
Demo
Be sure to play with the Designcember Calculator demo and check out the source code on Glitch. (To install the app, you need to open it in its own window. The embedded version below will not trigger the mini infobar.)
Happy Designcember!