diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-19 15:44:24 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-19 15:44:24 -0500 |
| commit | 96bc42fafff425cf7690ad417cc6d4d4a757547b (patch) | |
| tree | 84bbb487e44d27bad51d7daef82d4a2e2336f59c /working | |
| parent | e0383618dfef4cf5dae331422c206cde308eb9dc (diff) | |
| download | archsetup-96bc42fafff425cf7690ad417cc6d4d4a757547b.tar.gz archsetup-96bc42fafff425cf7690ad417cc6d4d4a757547b.zip | |
docs: file interface design references
Record the reconciled weather work and future component direction.\nFile the clock and retro-interface reference materials for later design work.
Diffstat (limited to 'working')
42 files changed, 1058 insertions, 0 deletions
diff --git a/working/clock-display-references/2026-07-17-clock-strip-concept-01.png b/working/clock-display-references/2026-07-17-clock-strip-concept-01.png Binary files differnew file mode 100644 index 0000000..6cd91c8 --- /dev/null +++ b/working/clock-display-references/2026-07-17-clock-strip-concept-01.png diff --git a/working/clock-display-references/2026-07-17-clock-strip-concept-02.png b/working/clock-display-references/2026-07-17-clock-strip-concept-02.png Binary files differnew file mode 100644 index 0000000..2edcebe --- /dev/null +++ b/working/clock-display-references/2026-07-17-clock-strip-concept-02.png diff --git a/working/clock-display-references/2026-07-17-clock-strip-concept-03.png b/working/clock-display-references/2026-07-17-clock-strip-concept-03.png Binary files differnew file mode 100644 index 0000000..fb14c0c --- /dev/null +++ b/working/clock-display-references/2026-07-17-clock-strip-concept-03.png diff --git a/working/clock-display-references/2026-07-17-clock-strip-concept-04.png b/working/clock-display-references/2026-07-17-clock-strip-concept-04.png Binary files differnew file mode 100644 index 0000000..d6bdd15 --- /dev/null +++ b/working/clock-display-references/2026-07-17-clock-strip-concept-04.png diff --git a/working/clock-display-references/2026-07-17-clock-strip-concept-05.png b/working/clock-display-references/2026-07-17-clock-strip-concept-05.png Binary files differnew file mode 100644 index 0000000..a2d3dda --- /dev/null +++ b/working/clock-display-references/2026-07-17-clock-strip-concept-05.png diff --git a/working/clock-display-references/2026-07-17-clock-strip-variant-sheet-01.png b/working/clock-display-references/2026-07-17-clock-strip-variant-sheet-01.png Binary files differnew file mode 100644 index 0000000..6ae249a --- /dev/null +++ b/working/clock-display-references/2026-07-17-clock-strip-variant-sheet-01.png diff --git a/working/clock-display-references/2026-07-17-clock-strip-variant-sheet-02.png b/working/clock-display-references/2026-07-17-clock-strip-variant-sheet-02.png Binary files differnew file mode 100644 index 0000000..eb82fd1 --- /dev/null +++ b/working/clock-display-references/2026-07-17-clock-strip-variant-sheet-02.png diff --git a/working/clock-display-references/2026-07-17-clock-strip-variant-sheet-03.png b/working/clock-display-references/2026-07-17-clock-strip-variant-sheet-03.png Binary files differnew file mode 100644 index 0000000..eb82fd1 --- /dev/null +++ b/working/clock-display-references/2026-07-17-clock-strip-variant-sheet-03.png diff --git a/working/clock-display-references/2026-07-18-chronograph-audemars-moonphase.png b/working/clock-display-references/2026-07-18-chronograph-audemars-moonphase.png Binary files differnew file mode 100644 index 0000000..bbf3404 --- /dev/null +++ b/working/clock-display-references/2026-07-18-chronograph-audemars-moonphase.png diff --git a/working/clock-display-references/2026-07-18-chronograph-blancpain-fifty-fathoms.png b/working/clock-display-references/2026-07-18-chronograph-blancpain-fifty-fathoms.png Binary files differnew file mode 100644 index 0000000..f4026c0 --- /dev/null +++ b/working/clock-display-references/2026-07-18-chronograph-blancpain-fifty-fathoms.png diff --git a/working/clock-display-references/2026-07-18-chronograph-breitling-navitimer.png b/working/clock-display-references/2026-07-18-chronograph-breitling-navitimer.png Binary files differnew file mode 100644 index 0000000..e518b2f --- /dev/null +++ b/working/clock-display-references/2026-07-18-chronograph-breitling-navitimer.png diff --git a/working/clock-display-references/2026-07-18-chronograph-date-idea-detail.png b/working/clock-display-references/2026-07-18-chronograph-date-idea-detail.png Binary files differnew file mode 100644 index 0000000..7f3c8a9 --- /dev/null +++ b/working/clock-display-references/2026-07-18-chronograph-date-idea-detail.png diff --git a/working/clock-display-references/2026-07-18-chronograph-date-idea.png b/working/clock-display-references/2026-07-18-chronograph-date-idea.png Binary files differnew file mode 100644 index 0000000..8bc8a4b --- /dev/null +++ b/working/clock-display-references/2026-07-18-chronograph-date-idea.png diff --git a/working/clock-display-references/2026-07-18-chronograph-date-window-detail.png b/working/clock-display-references/2026-07-18-chronograph-date-window-detail.png Binary files differnew file mode 100644 index 0000000..4ae7212 --- /dev/null +++ b/working/clock-display-references/2026-07-18-chronograph-date-window-detail.png diff --git a/working/clock-display-references/2026-07-18-chronograph-iwc-portugieser.png b/working/clock-display-references/2026-07-18-chronograph-iwc-portugieser.png Binary files differnew file mode 100644 index 0000000..d9d4616 --- /dev/null +++ b/working/clock-display-references/2026-07-18-chronograph-iwc-portugieser.png diff --git a/working/clock-display-references/2026-07-18-chronograph-omega-speedmaster.png b/working/clock-display-references/2026-07-18-chronograph-omega-speedmaster.png Binary files differnew file mode 100644 index 0000000..c62bf25 --- /dev/null +++ b/working/clock-display-references/2026-07-18-chronograph-omega-speedmaster.png diff --git a/working/clock-display-references/2026-07-18-chronograph-panerai-luminor-marina.png b/working/clock-display-references/2026-07-18-chronograph-panerai-luminor-marina.png Binary files differnew file mode 100644 index 0000000..b5a8d97 --- /dev/null +++ b/working/clock-display-references/2026-07-18-chronograph-panerai-luminor-marina.png diff --git a/working/clock-display-references/2026-07-18-chronograph-patek-moonphase.png b/working/clock-display-references/2026-07-18-chronograph-patek-moonphase.png Binary files differnew file mode 100644 index 0000000..a701931 --- /dev/null +++ b/working/clock-display-references/2026-07-18-chronograph-patek-moonphase.png diff --git a/working/clock-display-references/2026-07-18-chronograph-patek-perpetual-calendar.png b/working/clock-display-references/2026-07-18-chronograph-patek-perpetual-calendar.png Binary files differnew file mode 100644 index 0000000..11a5ac8 --- /dev/null +++ b/working/clock-display-references/2026-07-18-chronograph-patek-perpetual-calendar.png diff --git a/working/clock-display-references/2026-07-18-chronograph-patek-two-register.png b/working/clock-display-references/2026-07-18-chronograph-patek-two-register.png Binary files differnew file mode 100644 index 0000000..3cfa626 --- /dev/null +++ b/working/clock-display-references/2026-07-18-chronograph-patek-two-register.png diff --git a/working/clock-display-references/2026-07-18-chronograph-reference-notes.txt b/working/clock-display-references/2026-07-18-chronograph-reference-notes.txt new file mode 100644 index 0000000..b94cafe --- /dev/null +++ b/working/clock-display-references/2026-07-18-chronograph-reference-notes.txt @@ -0,0 +1,20 @@ +Chronograph / watch-face design inspiration — 12 reference images. + +Collected by Craig and dropped from a home session on 2026-07-18 for the +chronograph / clock-panel UI work. Each file is chronograph-inspiration-NN-<model>.png. + +Identified: +01 Blancpain Fifty Fathoms +02 Omega Speedmaster Professional (Moonwatch) +03 Rolex Submariner Date +05 Panerai Luminor Marina +07 IWC Portugieser Chronograph +08 Breitling Navitimer +09 Patek Philippe perpetual calendar chronograph (resembles ref 1518) +10 Audemars Piguet perpetual calendar moonphase +11 Patek Philippe two-register chronograph (resembles ref 130) +12 Patek Philippe perpetual calendar moonphase (3940/5140 family) + +Uncertain from the crop (relabel if the model is known): +04 dial date-window detail — model unidentified +06 Rolex fluted bezel + Cyclops date — Datejust or Day-Date, not certain diff --git a/working/clock-display-references/2026-07-18-chronograph-rolex-cyclops-detail.png b/working/clock-display-references/2026-07-18-chronograph-rolex-cyclops-detail.png Binary files differnew file mode 100644 index 0000000..1050032 --- /dev/null +++ b/working/clock-display-references/2026-07-18-chronograph-rolex-cyclops-detail.png diff --git a/working/clock-display-references/2026-07-18-chronograph-rolex-submariner-date.png b/working/clock-display-references/2026-07-18-chronograph-rolex-submariner-date.png Binary files differnew file mode 100644 index 0000000..e7bd485 --- /dev/null +++ b/working/clock-display-references/2026-07-18-chronograph-rolex-submariner-date.png diff --git a/working/clock-display-references/README.org b/working/clock-display-references/README.org new file mode 100644 index 0000000..f3b9bf2 --- /dev/null +++ b/working/clock-display-references/README.org @@ -0,0 +1,6 @@ +#+TITLE: Clock Display Reference Set + +Clock and chronograph reference material received during the 2026-07-19 inbox +zero pass. The concept renders establish the compact date/time-strip direction; +the watch photographs are reference-only for dial hierarchy, date treatment, +and complication spacing. They are not assets to reproduce or redistribute. diff --git a/working/retro-stereo-widgets/references/2026-07-18-dual-concentric-knob-black-detail.jpeg b/working/retro-stereo-widgets/references/2026-07-18-dual-concentric-knob-black-detail.jpeg Binary files differnew file mode 100644 index 0000000..d723827 --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-18-dual-concentric-knob-black-detail.jpeg diff --git a/working/retro-stereo-widgets/references/2026-07-18-dual-concentric-knob-black.jpg b/working/retro-stereo-widgets/references/2026-07-18-dual-concentric-knob-black.jpg Binary files differnew file mode 100644 index 0000000..8b2ed8e --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-18-dual-concentric-knob-black.jpg diff --git a/working/retro-stereo-widgets/references/2026-07-18-dual-concentric-knob-fender-detail.jpeg b/working/retro-stereo-widgets/references/2026-07-18-dual-concentric-knob-fender-detail.jpeg Binary files differnew file mode 100644 index 0000000..6a07d1d --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-18-dual-concentric-knob-fender-detail.jpeg diff --git a/working/retro-stereo-widgets/references/2026-07-18-dual-concentric-knob-fender.webp b/working/retro-stereo-widgets/references/2026-07-18-dual-concentric-knob-fender.webp Binary files differnew file mode 100644 index 0000000..8223796 --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-18-dual-concentric-knob-fender.webp diff --git a/working/retro-stereo-widgets/references/2026-07-18-dual-concentric-knob-guitar-detail.jpeg b/working/retro-stereo-widgets/references/2026-07-18-dual-concentric-knob-guitar-detail.jpeg Binary files differnew file mode 100644 index 0000000..2f2d72c --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-18-dual-concentric-knob-guitar-detail.jpeg diff --git a/working/retro-stereo-widgets/references/2026-07-18-dual-concentric-knob-guitar-pair.jpg b/working/retro-stereo-widgets/references/2026-07-18-dual-concentric-knob-guitar-pair.jpg Binary files differnew file mode 100644 index 0000000..fecefdd --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-18-dual-concentric-knob-guitar-pair.jpg diff --git a/working/retro-stereo-widgets/references/2026-07-18-dual-concentric-knob-style-sheet.jpeg b/working/retro-stereo-widgets/references/2026-07-18-dual-concentric-knob-style-sheet.jpeg Binary files differnew file mode 100644 index 0000000..3962c9f --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-18-dual-concentric-knob-style-sheet.jpeg diff --git a/working/retro-stereo-widgets/references/2026-07-18-dual-concentric-knob-tone-volume.jpg b/working/retro-stereo-widgets/references/2026-07-18-dual-concentric-knob-tone-volume.jpg Binary files differnew file mode 100644 index 0000000..e6e0cc0 --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-18-dual-concentric-knob-tone-volume.jpg diff --git a/working/retro-stereo-widgets/references/2026-07-18-hotfx-split-flap-reference.org b/working/retro-stereo-widgets/references/2026-07-18-hotfx-split-flap-reference.org new file mode 100644 index 0000000..5fa7acd --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-18-hotfx-split-flap-reference.org @@ -0,0 +1,65 @@ +#+TITLE: HotFX split-flap web component — technique reference +#+DATE: [2026-07-18 Sat] + +Reference for the split-flap display work (gallery card N20, and any flip/drum +date-time module the waybar chronograph grows). Craig supplied both links. + +- Annotated walkthrough: https://fx.hot.page/split-flap/source +- Source on GitHub: https://github.com/hot-page/fx/blob/main/hotfx-split-flap/hotfx-split-flap.js +- Local study copy: [[file:2026-07-18-hotfx-split-flap-source.js]] + +LICENSE CAUTION: the fx repo declares NO license (checked via the GitHub API +2026-07-18), so the code is all-rights-reserved by default. Study the +technique, re-derive our own implementation in the GW namespace — do not copy +code or CSS verbatim into the gallery or dotfiles. + +* The mechanics worth stealing (as ideas) + +** Four-layer character cell +Each character is four absolutely-stacked half-panels: =.top= / =.bottom= +(current char) and =.top-next= / =.bottom-next= (incoming char), the tops +clipped to the upper half and bottoms to the lower half, with a divider line +at 50%. =transform-style: preserve-3d= on the cell, +=backface-visibility: hidden= on flaps so a flap's reverse never shows. + +** One flip = two synchronized quarter-animations +Only two elements move per flip, via the Web Animations API with +=Promise.all= on their =.finished=: +- current =.top= rotates =rotateX(0deg)= → =rotateX(-180deg)= (falls forward) +- =.bottom-next= rotates =rotateX(180deg)= → =rotateX(0deg)= (lands) +Both =ease-in=, default 150ms per flap. After landing, the static halves get +the new character's text and the cycle repeats. This is the minimal honest +split-flap kinematic. + +** The charset IS the drum +It never jumps to the target character: it steps =currentIndex + 1= (wrapping) +through the declared alphabet until it arrives, one flip per step — the +Solari constraint that a board can only advance one flap at a time. The +alphabet is a declared attribute (default =' ABC…Z0-9!.,:?"'/$'=), so the +character order is data, exactly our R58 LAYOUT-as-data pattern: rewrite the +alphabet and the physical behavior follows. + +** Live-update semantics (relevant for a clock) +A MutationObserver rebuilds the target grid on text change; an in-flight flip +cascade aborts itself by identity-checking the target grid each step, and the +new animation starts only after awaiting every running animation's finish. +That's the answer to "the minute changed while the drum was still spinning" — +abort-and-retarget, never queue. + +** Also noted +IntersectionObserver defers the animation until the element is half in view; +=::part(flap-N-M)= attributes expose every flap and divider for external +styling. ~300 lines total for all of it. + +* How it maps to our targets + +- Gallery N20 (split-flap board): the four-layer cell + two-animation flip is + the web implementation; the step-through-charset rule and abort-on-retarget + are behaviors our probe checks can pin regardless of implementation. +- Waybar chronograph drums (the redesign mock's date/time): our drums are + odometer rollers, not flaps — different mechanism — but the charset-as-drum + ordering and abort-and-retarget rules carry over unchanged. +- Emacs svg.el port: no Web Animations API; the flip becomes frame-stepped + whole-image regeneration per the render contract, with the same + one-step-at-a-time rule. Reduced-motion gate applies (widget-owned + animation per the gallery tick contract). diff --git a/working/retro-stereo-widgets/references/2026-07-18-hotfx-split-flap-source.js b/working/retro-stereo-widgets/references/2026-07-18-hotfx-split-flap-source.js new file mode 100644 index 0000000..40539ad --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-18-hotfx-split-flap-source.js @@ -0,0 +1,455 @@ +// Create a custom element by extending `HTMLElement`. +export class HotFXSplitFlap extends HTMLElement { + // The internal state of the component is handled in private instance + // variables, which in JavaScript become private when they start with `#`. + // + // `hasAnimated` is a flag that keeps track of whether the animation has + // already completed. + #hasAnimated = false + // `isVisible` is a flag that keeps track of whether the element is currently + // visible in the viewport. + #isVisible = false + // The `IntersectionObserver` fires a callback that lets us know if the element + // is visible in the window or not. + #intersectionObserver + // The `MutationObserver` tells the element when it's content changes, so we + // can re-render the grid and animate it again. + #mutationObserver + // The `#currentGrid` is the current state of the text. The characters are + // stored as a two-dimensional array: each row is an array of characters and + // the grid is an array of rows. + #currentGrid + // The `#targetGrid` shows the ultimate destination of the + #targetGrid + + // Here we tell the browser to fire `attributeChangedCallback` when one of + // the `width`, `height`, or `duration` attributes changes. + static get observedAttributes() { + return ['width', 'height', 'duration', 'characcters'] + } + + constructor() { + // Always call your `super()` or the landlord will be mad. + super() + // Create a shadow DOM for this element that will hold all the elements and + // stlyes needed to create the flaps. + this.attachShadow({ mode: 'open' }) + } + + connectedCallback() { + // First do some validation on the attributes. Although all of them are + // optional, they are required to be positive integers if set. + ['width', 'height', 'duration'].forEach(name => { + if (!this.hasAttribute(name)) return + const value = parseInt(this.getAttribute(name)) + if (isNaN(value) || value <= 0) this.#printNumberError(name) + }) + + // If this element has already been mounted elsewhere the `#hasAnimated` + // flag may be set even though we're coming in fresh here. Let's reset it. + this.#hasAnimated = false + // The `#render()` function will create the grid and the flaps inside the + // shadow DOM. + this.#render() + + // Setup our `IntersectionObserver`, which will fire a callback function + // when this element scrolls into view. + this.#intersectionObserver = new IntersectionObserver( + (entries) => { + // Since we are only observing one element, the callback should always + // fire with one value in `entries`. + this.#isVisible = entries[0].isIntersecting + // The animation should only run the first time that we scoll onto it + // so only trigger the animation it if #hasAnimated is false. + if (this.#isVisible && !this.#hasAnimated) this.#animate() + }, + // Fire the callback when 50% of this element has scrolled into view. + { threshold: 0.5 } + ) + // Observe this element + this.#intersectionObserver.observe(this) + + // Setup the `MutationObserver` that will re-run the animation when the + // text inside this element changes. + this.#mutationObserver = new MutationObserver(async () => { + // The `#targetGrid` is the string of text that the element will animate + // towards. + this.#updateTargetGrid() + // First wait for the current animations to finish so there's no glitch + // when the new animations start. + await Promise.all( + Array.from(this.shadowRoot.querySelectorAll('.char')) + .map(char => char.getAnimations()) + .flat() + .map(animation => animation.finish()) + ) + // If the element is currently in view, start the animation. + if (this.#isVisible) this.#animate() + }) + // Observe this element, along with the entire tree under it. + this.#mutationObserver.observe( + this, + { childList: true, characterData: true, subtree: true }, + ) + } + + // Clean up the observers so that memory doesn't leak out of the browser, + // causing it to lose all of it's life force. + disconnectedCallback() { + this.#intersectionObserver?.disconnect() + this.#intersectionObserver = undefined + this.#mutationObserver?.disconnect() + this.#mutationObserver = undefined + } + + // The browser will call this when one of the `observedAttributes` changes. + attributeChangedCallback(name, oldValue, newValue) { + // Do some validation of the new value to warn the page author if they've + // added an invalid value. + if (['width', 'height', 'duration'].includes(name)) { + const value = parseInt(this.getAttribute(name)) + if (isNaN(value) || value <= 0) return this.#printNumberError(name) + } + // If the attribute that was changed is `duration` we don't neeed to do + // anything because it will be picked up in the next animation. + if (name == 'duration' || oldValue == newValue) return + // If `width` or `height` change, we will need to re-render the structure + // of the shadow DOM to account for the new size. + this.#render() + // Finally, restart the animation if this element is currently in the + // viewport. After rendering, the tiles are all on the first letter which is + // the space character by default. + if (this.#isVisible) this.#animate() + } + + // Getter for `width` property called when reading `element.width`. + get width() { + // If there is a `#currentGrid`, report this size. + if (this.#currentGrid) return this.#currentGrid[0].length + // Otherwise, try to use the attribute if set. + if (this.hasAttribute('width')) { + const width = parseInt(this.getAttribute('width')) + if (Number.isInteger(width) && width > 0) return width + } + // Finally, if there is no grid and no attribute, we default to the size of + // the text inside this element, specifically the longest line. + return Math.max(...this.textContent.split('\n').map(line => line.length)) + } + + // Reflect the `.width` property back to the `width=` attribute. + set width(value) { + // Validate the new value which should be a positive integer. + if (!Number.isInteger(value) || value <= 0) this.#printNumberError('width') + // Reflect the new value to the attribute and re-render the shadow DOM + this.setAttribute('width', value) + this.#render() + // If the element is in the viewport, run the animation now. + if (this.#isVisible) this.#animate() + } + + // Getter for `.height` property similar to above for `width`, but returning + // the number of lines in the text content instead of the longest line. + get height() { + if (this.#currentGrid) return this.#currentGrid.length + if (this.hasAttribute('height')) { + const height = parseInt(this.getAttribute('height')) + if (Number.isInteger(height) && height > 0) return height + } + return this.textContent.split('\n').length + } + + // Setter for `.height` property, same as above for `width`. + set height(value) { + if (!Number.isInteger(value) || value <= 0) this.#printNumberError('height') + this.setAttribute('height', value) + this.#render() + if (this.#isVisible) this.#animate() + } + + // Get the duration of the animation which is how long it takes for one flap + // to fall down from top to bottom. + get duration() { + // The duration defaults to 150 milliseconds or 0.15 seconds for each flap + // to fall once. + let duration = 150 + // Check if there's a `duration` attribute and use that value if it's a + // positive integer. + if (this.hasAttribute('duration')) { + const durationAttr = parseInt(this.getAttribute('duration')) + if (Number.isInteger(durationAttr) && durationAttr > 0) duration = durationAttr + } + return duration + } + + // Setter for duration that reflects to the attribute with valdiation logic. + set duration(value) { + if (typeof value !== 'number' || value <= 0) return this.#printNumberError('duration') + this.setAttribute('duration', value) + } + + // Getter for `.characters` property. This is the alphabet used for the flaps. + // Here we're just pulling from the attribute which has the current value or + // using the default Latin alphabet. + get characters() { + return this.getAttribute('characters') || ' ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!.,:?"\'/$' + } + + // Setter for `.characters` property that reflects to the attribute. + set characters(value) { + this.setAttribute('characters', value) + } + + // Helper function that turns the text of this element into the grid of + // letters that we're going to animate towards. The grid is stored as a + // two-dimensional array of characters. + #updateTargetGrid() { + // Split the text on newline to get an array of lines. + const lines = this.textContent.split('\n') + // Note that we're using the `.width` and `.height` properties because the + // dimensions of the grid could have been set explicitly using attributes. + this.#targetGrid = Array.from({ length: this.height }).map((_, row) => { + const line = lines[row] || '' + return Array.from({ length: this.width }).map( + (_, col) => line[col]?.toUpperCase() || ' ', + ) + }) + } + + // Render the grid of letters into the shadow DOM. + #render() { + // First set `#currentGrid` to null because it will be used to calculate the + // width and height of the next grid and we need to start fresh. + this.#currentGrid = null + // The `#currentGrid` is the current state of the letters displayed. Since + // we're starting over fresh here, we just make it all space characters. + this.#currentGrid = Array.from({ length: this.height }, () => + Array.from({ length: this.width }, () => ' ') + ) + this.#updateTargetGrid() + + this.shadowRoot.innerHTML = [` + <style> + :host { + display: inline-block; + font-family: Arial, sans-serif; + } + + #container { + display: grid; + grid-template-columns: repeat(${this.width}, auto); + grid-template-rows: repeat(${this.height}, auto); + gap: var(--hotfx-split-flap-grid-gap, .1em); + perspective: 1000px; + }`, + // Each character has height and width set explicitly which is not ideal + // but that way we have a grid of same-sized objects. If a given font + // has very wide characters, this can be overwritten using the + // `::part(char)` pseudo-element. + `.char { + position: relative; + width: 1em; + height: 1.2em; + transform-style: preserve-3d; + }`, + // These are the flaps that rotate to reveal each character. They are + // four elements positioned on top of each other, two for the current + // character and two for the next character -- each split into top + // and bottom halves. + `.top, .bottom, .top-next, .bottom-next { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + color: white; + background: black;`, + // backface-visibility is important to hide the back of the flap when + // it is rotated more than 90 degrees. + `backface-visibility: hidden; + }`, + // The top flaps have their bottoms cut off and the bottom flaps have + // their tops cut off. + `.top, .top-next { + clip-path: polygon(0 0, 100% 0, 100% 50%, 0 50%); + } + + .bottom, .bottom-next { + clip-path: polygon(0 50%, 100% 50%, 100% 100%, 0 100%); + }`, + // The divider is the line in the middle of the flap. + `.divider { + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 1px; + background-color: currentColor; + color: #999; + z-index: 10; + transform: translateY(-50%); + } + </style> + <div id="container">`, + // Create the grid of characters by mapping over the `#currentGrid` + // (in both dimensions) + this.#currentGrid.map((row, rowNumber) => { + // Row and column numbers used in `::part()` start at 1 as per CSS + // convention, e.g. `nth-child(1)` + rowNumber += 1 + return row.map((char, columnNumber) => { + columnNumber += 1 + const currentChar = char === ' ' ? ' ' : char + // Each character and its flaps have several part= attribute values + // so they can be addressed in CSS, for example + // - `::part(char-1)` for all the characters in the first column + // - `::part(flap-3-4)` for the flaps in the third row and + // fourth column + return ` + <span + class=char + part="char char-${columnNumber} char-${rowNumber}-${columnNumber}"> + <span + class=top-next + part="flap flap-${columnNumber} flap-${rowNumber}-${columnNumber}" + aria-hidden=true> + ${currentChar} + <span + class=divider + part="divider flap-${columnNumber} divider-${rowNumber}-${columnNumber}"> + </span> + </span> + <span + class=bottom-next + part="flap flap-${columnNumber} flap-${rowNumber}-${columnNumber}" + aria-hidden=true> + ${currentChar} + <span + class=divider + part="divider flap-${columnNumber} divider-${rowNumber}-${columnNumber}"> + </span> + </span> + <span + class=top + part="flap flap-${columnNumber} flap-${rowNumber}-${columnNumber}"> + ${currentChar} + <span + class=divider + part="divider flap-${columnNumber} divider-${rowNumber}-${columnNumber}"> + </span> + </span> + <span + class=bottom + part="flap flap-${columnNumber} flap-${rowNumber}-${columnNumber}" + aria-hidden=true> + ${currentChar} + <span + class=divider + part="divider flap-${columnNumber} divider-${rowNumber}-${columnNumber}"> + </span> + </span> + </span> + ` + }).join('') + }).join(''), + `</div>` + ].join('') + } + + // To animate, the element must rotate each flap, then swap out the characters + // in the DOM and then repeat through the whole alphabet until it arrives at + // the correct end character. + #animate() { + // If the animation has already run, don't run it again. + this.#hasAnimated = true + + // Create a copy of the target grid so that we can check if it since been + // updated and the animation should be aborted (since another one will + // be triggered). + const targetGrid = this.#targetGrid + + // Animate each character. + this.shadowRoot.querySelectorAll('.char').forEach((char, index) => { + // Find the index of this charcter in the grid. + const row = Math.floor(index / this.width) + const col = index % this.width + + // The index of the target character in our alphabet. + const targetCharIndex = this.characters.includes(targetGrid[row][col]) + ? this.characters.indexOf(targetGrid[row][col]) + : 0 + + // The current character index in the alphabet. + let currentCharIndex = this.characters.indexOf(this.#currentGrid[row][col]) + + // If the indexes are the same, it's already finished and this character + // should not be animated at all + if (currentCharIndex == targetCharIndex) return + + // This function will run one flap and wait for it to finsih before + // calling itself again to animate the next flap. + const flap = async () => { + // If the target grid has changed since we started animating, abort + // this animation since another one will be triggered. + if (targetGrid != this.#targetGrid) return + + // Find the index of the character following the current one, wrapping + // around to the start if needed. + const nextCharIndex = currentCharIndex < this.characters.length - 1 + ? currentCharIndex + 1 + : 0 + + // The next character to animate towards + const nextChar = this.characters[nextCharIndex] + + // Set the text content of the top and bottom flaps to the next character + char.querySelector('.top-next').childNodes[0].textContent = nextChar + char.querySelector('.bottom-next').childNodes[0].textContent = nextChar + + // Anaimte the top flap and the bottom of the next flap to fall from the + // top. Using `await Promise.all()` to wait for both animations to + // finish. + const options = { duration: this.duration, easing: 'ease-in' } + await Promise.all([ + char.querySelector('.top').animate([ + { transform: 'rotateX(0deg)' }, + { transform: 'rotateX(-180deg)' } + ], options).finished, + char.querySelector('.bottom-next').animate([ + { transform: 'rotateX(180deg)' }, + { transform: 'rotateX(0deg)' } + ], options).finished + ]) + + // Swap out the top and bottom flaps and set the `currentCharIndex` value + // so that the next character is now the currect character. + char.querySelector('.top').childNodes[0].textContent = nextChar + char.querySelector('.bottom').childNodes[0].textContent = nextChar + currentCharIndex = nextCharIndex + // Update the #currentGrid with the new character. + this.#currentGrid[row][col] = this.characters[currentCharIndex] + // If the new currrent character is still not the target character, + // continue animating by calling this `flap()` function again. + if (currentCharIndex !== targetCharIndex) flap() + } + + // Trigger the first animation for this character. + flap() + }) + } + + // Helper function to print errors for invalid attributes. + #printNumberError(name) { + console.error(`<hotfx-split-flap> ${name} attribute must be a positive integer; got "${this.getAttribute(name)}"`) + } + +} + +// Checks for a `?define` query parameter in the URL that was used to load this +// script. So, for instance, if you load the script with `<script type="module" src="https://cdn.jsdelivr.net/npm/@hot-page/hotfx-counter@0.0.0?define=false">`, +// this variable will contain the text "false". +const define = new URL(import.meta.url).searchParams.get("define") +// By default we register the above class as a custom element so that we can +// use it in HTML with the `<hotfx-counter>` tag. That is, unless called with +// the query parameter to prevent this. +if (define != 'false') customElements.define('hotfx-split-flap', HotFXSplitFlap) diff --git a/working/retro-stereo-widgets/references/2026-07-18-single-knob-black.webp b/working/retro-stereo-widgets/references/2026-07-18-single-knob-black.webp Binary files differnew file mode 100644 index 0000000..95c312f --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-18-single-knob-black.webp diff --git a/working/retro-stereo-widgets/references/2026-07-18-single-knob-index-arrow.jpeg b/working/retro-stereo-widgets/references/2026-07-18-single-knob-index-arrow.jpeg Binary files differnew file mode 100644 index 0000000..41acdd8 --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-18-single-knob-index-arrow.jpeg diff --git a/working/retro-stereo-widgets/references/2026-07-18-split-flap-header.webp b/working/retro-stereo-widgets/references/2026-07-18-split-flap-header.webp Binary files differnew file mode 100644 index 0000000..b031946 --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-18-split-flap-header.webp diff --git a/working/retro-stereo-widgets/references/2026-07-18-terminal-display.jpg b/working/retro-stereo-widgets/references/2026-07-18-terminal-display.jpg Binary files differnew file mode 100644 index 0000000..7eff3fe --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-18-terminal-display.jpg diff --git a/working/weather-kit-integration/README.org b/working/weather-kit-integration/README.org new file mode 100644 index 0000000..d04899f --- /dev/null +++ b/working/weather-kit-integration/README.org @@ -0,0 +1,119 @@ +#+TITLE: Weather kit — for the Dupre kit / waybar / Wayland UX +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-18 + +Built to completion elsewhere and handed to archsetup, which owns the Dupre kit, +waybar, and the Wayland UI/UX and will maintain it from here. This note is the +integration guide: what's in the package, the data contract, and how each +surface consumes it. + +* What's in the package + +| File | Purpose | +|----------------------+-------------------------------------------------------------------------| +| =weather= | The fetcher. Python 3, stdlib only. Hits Open-Meteo, normalises, caches. | +| =weather-glyphs.svg= | The custom condition glyph set (9 symbols, currentColor). | +| =weather-chip.html= | Reference renderer: the chosen chip, wired to the contract. Port target.| +| =README.org= | This file. | + +* The design decisions it encodes (settled over the 2026-07-18 session) + +- *Source: Open-Meteo, =model=best_match=.* No API key, no signup, non-commercial + free tier (10k calls/day; a 15-min poll is ~96/day). =best_match= auto-selects + the most accurate regional model — NOAA blend in the US, ECMWF/ICON abroad — so + the same binary is right on ratio in New Orleans and on velox travelling. For a + bar chip there's no perceptible accuracy gap vs the NWS API, and NWS is US-only, + so Open-Meteo is the single source. (NWS stays a possible *additive* feature + later, only for official US severe-weather alerts.) +- *One fetcher, one cache, thin renderers.* Every surface shells out to =weather= + and renders the JSON. Nobody re-implements the API call or the cache. A shared + cache means several widgets polling still hit the API at most once per TTL and + all show the identical reading. +- *Windy is a condition, not a compass reading.* Below the threshold the chip + shows the direction vane + speed; at/above it, the gust glyph + speed. Same + width either way. Threshold default *15 mph* (Beaufort 4 — where wind stops + being ambient). It's a =--windy-threshold= flag, derived at output from + =wind_mph=, so changing it takes effect without a cache bust. +- *Custom glyphs, not Nerd Font.* Seven conditions + a gust mark + a vane, drawn + to sit in the instrument aesthetic. =currentColor=, so each surface recolours. + +* The contract (=weather --json=) + +#+begin_src json +{ + "temp_f": 82, "feels_f": 92, + "condition": "clear", // clear | partly | cloud | fog | rain | snow | storm + "wmo_code": 0, + "windy": false, // wind_mph >= threshold + "wind_mph": 6, "wind_dir": "WNW", "wind_deg": 293, + "location": "New Orleans", + "observed": "2026-07-18T01:00", "stale": false +} +#+end_src + +=condition= is the coupling to the glyphs. Mapping (also in =weather-chip.html=): +=clear->wx-sun=, =partly->wx-partly=, =cloud->wx-cloud=, =fog->wx-fog=, +=rain->wx-rain=, =snow->wx-snow=, =storm->wx-storm=; plus =wx-wind= (shown when +=windy=) and =wx-vane= (shown otherwise, rotated by =wind_deg=). A consumer never +sees the WMO number unless it wants =wmo_code=. + +=stale: true= means the network fetch failed and this is the last good reading — +render it dimmed, never blank the bar. + +* WMO weather-code -> condition (what the fetcher collapses) + +| WMO code(s) | condition | +|--------------------------------------+-----------| +| 0 | clear | +| 1, 2 | partly | +| 3 | cloud | +| 45, 48 | fog | +| 51-57, 61-67, 80-82 | rain | +| 71-77, 85-86 | snow | +| 95, 96, 99 | storm | +| (anything unmapped) | cloud | + +* Install + +Drop =weather= on =PATH= the way the other fleet CLIs travel (=agent-page=, etc.): +into the rulesets/dotfiles bin so =make install= / stow links it to +=~/.local/bin/weather= on every machine. It's dependency-free, so nothing else to +provision. + +No coordinates are baked in (nothing personal ships in the repo). Each machine +sets its own location, resolved in order: + +1. =--lat/--lon= flags. +2. =$WEATHER_LAT= / =$WEATHER_LON= (and optional =$WEATHER_NAME=). +3. =~/.config/weather/config.json= — ={"lat":29.97,"lon":-90.09,"name":"New Orleans"}=. + Add ="use_geo": true= (or pass =--geo=) to prefer =whereami= — the right call + on velox, which travels. + +* How each surface consumes it + +- *waybar* — a custom module: + #+begin_src jsonc + "custom/weather": { "exec": "weather --format waybar", "return-type": "json", + "interval": 900, "on-click": "..." } + #+end_src + =--format waybar= emits ={text, alt, class, tooltip}=; the stylesheet picks the + icon by =alt=/=class= (=.clear=, =.windy=, =.stale=, ...). +- *Dupre kit / =svg.el=* — =(json-parse-string (shell-command-to-string "weather --json"))=, + then render the chip. =weather-chip.html='s =renderChip(reading)= is the exact + logic to port: glyph, temp, wind slot (gust vs vane by =windy=). +- *clock-panel (Python)* — =json.loads(subprocess.run(["weather","--json"],...).stdout)=. + No HTTP in clock-panel at all. + +* Notes / open choices for the maintainer + +- Poll cadence lives with the consumer (waybar =interval=), not the tool; the tool + just honours its own cache TTL (=--ttl=, default 900s). +- =weather-glyphs.svg= is the canonical glyph source. =weather-chip.html= inlines a + copy for a standalone demo — if the glyphs change, update both (or have the kit + build inline from the =.svg=). +- The chip currently shows the wind slot always. If it ever reads busy on a packed + bar, hiding the slot below the threshold (show only when windy) is a one-line + change — the data's already there. +- A "windy later today" hint would want the hourly endpoint (=hourly=wind_speed_10m=) + and a small max-over-next-N-hours; the fetcher is structured to add that as a + second field without touching consumers. diff --git a/working/weather-kit-integration/weather b/working/weather-kit-integration/weather new file mode 100755 index 0000000..6d0c0f1 --- /dev/null +++ b/working/weather-kit-integration/weather @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""weather -- one fetcher for the whole fleet. + +Hits Open-Meteo (model=best_match, so NOAA blend in the US, ECMWF/ICON abroad), +normalises the reading, caches the last good value, and prints it. Every app +(waybar, clock-panel, an Emacs svg.el panel) shells out to this and renders the +JSON -- nobody re-implements the API call, the code mapping, or the cache. + +Stdlib only: no pip, no venv. Runs as a bare executable. + +Contract (--format json): + { + "temp_f": 82, "feels_f": 92, + "condition": "clear", # clear|partly|cloud|fog|rain|snow|storm + "wmo_code": 0, + "windy": false, # wind_mph >= threshold (default 15) + "wind_mph": 6, "wind_dir": "WNW", "wind_deg": 293, + "location": "New Orleans", + "observed": "2026-07-18T01:00", "stale": false + } + +Location resolves in order: --lat/--lon, then $WEATHER_LAT/$WEATHER_LON, then +~/.config/weather/config.json, then `whereami` (with --geo). No coordinates are +baked in, so nothing personal ships in the repo -- each machine configures its +own. +""" +import argparse +import json +import os +import subprocess +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +API = "https://api.open-meteo.com/v1/forecast" +CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "weather" / "config.json" +CACHE = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "weather" / "current.json" +DEFAULT_TTL = 900 # 15 min -- weather changes slowly; keep API calls rare +DEFAULT_WINDY = 15 # mph; Beaufort 4, where wind stops being ambient + +# WMO weather-interpretation code -> our semantic condition. +# Ranges collapse to the seven condition glyphs; "windy" is derived from speed. +_WMO = { + 0: "clear", + 1: "partly", 2: "partly", + 3: "cloud", + 45: "fog", 48: "fog", + 51: "rain", 53: "rain", 55: "rain", 56: "rain", 57: "rain", + 61: "rain", 63: "rain", 65: "rain", 66: "rain", 67: "rain", + 80: "rain", 81: "rain", 82: "rain", + 71: "snow", 73: "snow", 75: "snow", 77: "snow", 85: "snow", 86: "snow", + 95: "storm", 96: "storm", 99: "storm", +} + +_CARDINALS = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", + "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"] + + +def condition_for(code): + return _WMO.get(int(code), "cloud") # unknown code -> cloud, the safe neutral + + +def cardinal(deg): + return _CARDINALS[int((deg % 360) / 22.5 + 0.5) % 16] + + +def resolve_location(args): + """Return (lat, lon, name). Raise if nothing configured.""" + if args.lat is not None and args.lon is not None: + return args.lat, args.lon, args.location + env_lat, env_lon = os.environ.get("WEATHER_LAT"), os.environ.get("WEATHER_LON") + if env_lat and env_lon: + return float(env_lat), float(env_lon), args.location or os.environ.get("WEATHER_NAME") + if CONFIG.exists(): + cfg = json.loads(CONFIG.read_text()) + if args.geo or cfg.get("use_geo"): + geo = _whereami() + if geo: + return geo[0], geo[1], args.location or cfg.get("name") + if "lat" in cfg and "lon" in cfg: + return cfg["lat"], cfg["lon"], args.location or cfg.get("name") + if args.geo: + geo = _whereami() + if geo: + return geo[0], geo[1], args.location + raise SystemExit( + "weather: no location. Pass --lat/--lon, set $WEATHER_LAT/$WEATHER_LON, " + f"write {CONFIG} (\"lat\"/\"lon\"[/\"name\"]), or use --geo on a machine " + "that travels." + ) + + +def _whereami(): + """Best-effort coords from the `whereami` tool. Only meaningful where the + machine actually moves (velox); silently gives up otherwise.""" + try: + out = subprocess.run(["whereami"], capture_output=True, text=True, timeout=20).stdout + except (OSError, subprocess.SubprocessError): + return None + for line in out.splitlines(): + if "coords:" in line: + try: + nums = line.split("coords:")[1].split("(")[0] + lat, lon = (float(x) for x in nums.replace(",", " ").split()[:2]) + return lat, lon + except (ValueError, IndexError): + return None + return None + + +def fetch(lat, lon, name, threshold): + params = ( + f"?latitude={lat}&longitude={lon}" + "¤t=temperature_2m,apparent_temperature,weather_code," + "wind_speed_10m,wind_direction_10m" + "&temperature_unit=fahrenheit&wind_speed_unit=mph" + "&models=best_match&timezone=auto" + ) + req = urllib.request.Request(API + params, headers={"User-Agent": "weather-cli/1.0"}) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.load(resp) + cur = data["current"] + mph = round(cur["wind_speed_10m"]) + deg = cur["wind_direction_10m"] + return { + "temp_f": round(cur["temperature_2m"]), + "feels_f": round(cur["apparent_temperature"]), + "condition": condition_for(cur["weather_code"]), + "wmo_code": int(cur["weather_code"]), + "windy": mph >= threshold, + "wind_mph": mph, + "wind_dir": cardinal(deg), + "wind_deg": round(deg), + "location": name or f"{lat:.2f},{lon:.2f}", + "observed": cur["time"], + "stale": False, + } + + +def read_cache(): + try: + blob = json.loads(CACHE.read_text()) + return blob["fetched_at"], blob["reading"] + except (OSError, ValueError, KeyError): + return None, None + + +def write_cache(reading): + CACHE.parent.mkdir(parents=True, exist_ok=True) + CACHE.write_text(json.dumps({"fetched_at": int(time.time()), "reading": reading})) + + +def get_reading(args): + """Cache-first, network-second, stale-cache-on-failure. One shared cache + means five widgets polling still hit the API at most once per TTL.""" + fetched_at, cached = read_cache() + if cached and not args.no_cache and fetched_at and (time.time() - fetched_at) < args.ttl: + return cached + lat, lon, name = resolve_location(args) + try: + reading = fetch(lat, lon, name, args.windy_threshold) + write_cache(reading) + return reading + except (urllib.error.URLError, OSError, KeyError, ValueError) as e: + if cached: # never blank the bar: serve last good, flagged + cached["stale"] = True + return cached + raise SystemExit(f"weather: fetch failed and no cache to fall back on ({e})") + + +def render(reading, fmt): + if fmt == "json": + return json.dumps(reading) + if fmt == "plain": + wind = "windy" if reading["windy"] else reading["wind_dir"] + s = f"{reading['temp_f']}°F {reading['condition']}, wind {wind} {reading['wind_mph']}" + return s + (" (stale)" if reading["stale"] else "") + if fmt == "waybar": + # Data only; the caller's stylesheet picks the icon by `alt`/`class`. + wind = "windy" if reading["windy"] else f"{reading['wind_dir']} {reading['wind_mph']}" + return json.dumps({ + "text": f"{reading['temp_f']}° {reading['wind_mph']}", + "alt": reading["condition"], + "class": (["stale"] if reading["stale"] else []) + [reading["condition"]] + + (["windy"] if reading["windy"] else []), + "tooltip": f"{reading['location']}: {reading['temp_f']}°F " + f"(feels {reading['feels_f']}°), {reading['condition']}, " + f"wind {reading['wind_dir']} {reading['wind_mph']} mph" + + (" [stale]" if reading["stale"] else ""), + }) + raise SystemExit(f"weather: unknown format {fmt!r}") + + +def main(argv=None): + p = argparse.ArgumentParser(description="Normalised current weather from Open-Meteo.") + p.add_argument("--lat", type=float, help="latitude override") + p.add_argument("--lon", type=float, help="longitude override") + p.add_argument("--location", help="display name for the reading") + p.add_argument("--geo", action="store_true", help="geolocate via `whereami` (traveling machines)") + p.add_argument("--format", choices=["json", "plain", "waybar"], default="json") + p.add_argument("--windy-threshold", type=int, default=DEFAULT_WINDY, help="mph at which windy=true") + p.add_argument("--ttl", type=int, default=DEFAULT_TTL, help="cache seconds") + p.add_argument("--no-cache", action="store_true", help="force a fresh fetch") + args = p.parse_args(argv) + reading = get_reading(args) + # Derive windy from cached wind_mph so a changed threshold takes effect + # without waiting for the cache to expire. + reading["windy"] = reading["wind_mph"] >= args.windy_threshold + print(render(reading, args.format)) + + +if __name__ == "__main__": + main() diff --git a/working/weather-kit-integration/weather-chip.html b/working/weather-kit-integration/weather-chip.html new file mode 100644 index 0000000..f9e05d4 --- /dev/null +++ b/working/weather-kit-integration/weather-chip.html @@ -0,0 +1,97 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<title>Weather chip — reference renderer</title> +<style> +:root{ + --panel:#100f0f; --ground:#151311; --cream:#f3e7c5; --steel:#969385; + --gold:#e2a038; --gold-hi:#ffbe54; --amber-edge:#7d5c16; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +body{background:var(--ground);color:var(--cream);font-family:var(--mono);padding:32px 4vw 80px} +h1{color:var(--cream);font-size:19px;margin-bottom:6px} +p{color:var(--steel);font-size:12.5px;max-width:820px;line-height:1.6;margin-bottom:8px} +code{color:var(--gold-hi)} +.row{display:flex;flex-wrap:wrap;gap:26px;align-items:flex-start;margin-top:26px} +figure{margin:0;display:flex;flex-direction:column;align-items:center;gap:10px} +figcaption{color:var(--steel);font-size:10px;letter-spacing:.14em;text-transform:uppercase} +/* the chip */ +.wx-chip{display:inline-flex;align-items:center;gap:8px;background:#080808;border-radius:8px; + padding:6px 11px;box-shadow:inset 0 2px 8px #000, 0 0 0 1px #33290f} +.wx-chip .temp{color:#e9e9e9;font-size:17px} +.wx-chip .sep{width:1px;align-self:stretch;background:#33290f;margin:2px 0} +.wx-chip .spd{color:var(--gold);font-size:13px} +.wx-chip svg{display:block} +</style> +</head> +<body> + +<h1>Weather chip — reference renderer</h1> +<p>The chosen bar form, wired to the <code>weather</code> CLI contract. Each chip below is built by <code>renderChip(reading)</code> from a contract object (the JSON <code>weather --json</code> prints). This is the reference for the port into the Dupre kit's <code>svg.el</code>: condition glyph, temperature, then the wind slot — which collapses to the gust glyph + speed when <code>windy</code>, or the direction vane + speed when calm. Constant width either way.</p> +<p>To drive it live: <code>weather --json | (feed into renderChip)</code>. The samples here are static so the condition and calm/windy mapping is visible at a glance.</p> + +<!-- the custom glyph set, inlined (same as weather-glyphs.svg) --> +<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs> + <symbol id="wx-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.3" fill="currentColor"/><g stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="12" y1="1.5" x2="12" y2="4.6"/><line x1="12" y1="19.4" x2="12" y2="22.5"/><line x1="1.5" y1="12" x2="4.6" y2="12"/><line x1="19.4" y1="12" x2="22.5" y2="12"/><line x1="4.6" y1="4.6" x2="6.8" y2="6.8"/><line x1="17.2" y1="17.2" x2="19.4" y2="19.4"/><line x1="4.6" y1="19.4" x2="6.8" y2="17.2"/><line x1="17.2" y1="6.8" x2="19.4" y2="4.6"/></g></symbol> + <symbol id="wx-partly" viewBox="0 0 24 24"><circle cx="8.5" cy="8" r="3.2" fill="currentColor"/><g stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="8.5" y1="1.5" x2="8.5" y2="3.4"/><line x1="1.7" y1="8" x2="3.6" y2="8"/><line x1="3.7" y1="3.2" x2="5" y2="4.5"/><line x1="13.3" y1="3.2" x2="12" y2="4.5"/></g><g fill="currentColor"><circle cx="12" cy="15" r="3.6"/><circle cx="16.5" cy="13" r="4.4"/><circle cx="20" cy="15.6" r="3.1"/><rect x="11" y="15" width="10" height="4" rx="2"/></g></symbol> + <symbol id="wx-cloud" viewBox="0 0 24 24"><g fill="currentColor"><circle cx="7.5" cy="13" r="4"/><circle cx="12.5" cy="10.5" r="5.2"/><circle cx="17" cy="13.5" r="3.6"/><rect x="6.5" y="13" width="11.5" height="4.2" rx="2.1"/></g></symbol> + <symbol id="wx-rain" viewBox="0 0 24 24"><g fill="currentColor"><circle cx="7.5" cy="9" r="3.6"/><circle cx="12.5" cy="7" r="4.6"/><circle cx="16.5" cy="9.6" r="3.1"/><rect x="6.5" y="9" width="10.5" height="3.8" rx="1.9"/></g><g stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="8" y1="15.5" x2="6.6" y2="19.5"/><line x1="12" y1="15.5" x2="10.6" y2="19.5"/><line x1="16" y1="15.5" x2="14.6" y2="19.5"/></g></symbol> + <symbol id="wx-storm" viewBox="0 0 24 24"><g fill="currentColor"><circle cx="7.5" cy="9" r="3.6"/><circle cx="12.5" cy="7" r="4.6"/><circle cx="16.5" cy="9.6" r="3.1"/><rect x="6.5" y="9" width="10.5" height="3.8" rx="1.9"/></g><path d="M12.5 14 L9 19.5 h3 L10.5 23.5 L15.5 17.5 h-3 L14 14 Z" fill="currentColor"/></symbol> + <symbol id="wx-snow" viewBox="0 0 24 24"><g fill="currentColor"><circle cx="7.5" cy="9" r="3.6"/><circle cx="12.5" cy="7" r="4.6"/><circle cx="16.5" cy="9.6" r="3.1"/><rect x="6.5" y="9" width="10.5" height="3.8" rx="1.9"/></g><g stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><g transform="translate(9,17.5)"><line x1="-2" y1="0" x2="2" y2="0"/><line x1="0" y1="-2" x2="0" y2="2"/><line x1="-1.4" y1="-1.4" x2="1.4" y2="1.4"/><line x1="-1.4" y1="1.4" x2="1.4" y2="-1.4"/></g><g transform="translate(15,18.5)"><line x1="-2" y1="0" x2="2" y2="0"/><line x1="0" y1="-2" x2="0" y2="2"/><line x1="-1.4" y1="-1.4" x2="1.4" y2="1.4"/><line x1="-1.4" y1="1.4" x2="1.4" y2="-1.4"/></g></g></symbol> + <symbol id="wx-fog" viewBox="0 0 24 24"><g stroke="currentColor" stroke-width="1.9" stroke-linecap="round"><line x1="3" y1="7" x2="21" y2="7"/><line x1="5" y1="11.5" x2="19" y2="11.5"/><line x1="3" y1="16" x2="18" y2="16"/><line x1="7" y1="20.5" x2="21" y2="20.5"/></g></symbol> + <symbol id="wx-wind" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"><path d="M2.5 8 H13.5 a2.6 2.6 0 1 0 -2.55 -3.1"/><path d="M2.5 12.5 H18 a2.9 2.9 0 1 1 -2.85 3.45"/><path d="M2.5 17 H10.5 a2.3 2.3 0 1 1 -2.25 2.75"/></g></symbol> + <symbol id="wx-vane" viewBox="0 0 24 24"><path d="M12 2.4 L15.6 9 L12.9 9 L12.9 21 L11.1 21 L11.1 9 L8.4 9 Z" fill="currentColor" stroke="currentColor" stroke-width=".8" stroke-linejoin="round"/></symbol> +</defs></svg> + +<div class="row" id="chips"></div> + +<script> +// condition -> glyph id. Only "clear" differs from its name (historically the sun). +const GLYPH = {clear:"wx-sun", partly:"wx-partly", cloud:"wx-cloud", + fog:"wx-fog", rain:"wx-rain", snow:"wx-snow", storm:"wx-storm"}; + +// The whole render: contract object in, chip element out. This is the logic to +// port to svg.el -- glyph, temp, then wind slot (gust glyph when windy, else the +// vane rotated by wind_deg). Same footprint either way. +function renderChip(r){ + const chip = document.createElement("div"); + chip.className = "wx-chip"; + const cond = GLYPH[r.condition] || "wx-cloud"; + chip.innerHTML = + `<svg width="24" height="24" style="color:var(--gold-hi)"><use href="#${cond}"/></svg>` + + `<span class="temp">${r.temp_f}°</span>` + + `<span class="sep"></span>` + + (r.windy + ? `<svg width="20" height="20" style="color:var(--gold-hi)"><use href="#wx-wind"/></svg>` + : `<svg width="18" height="18" style="color:var(--gold-hi);transform:rotate(${r.wind_deg}deg);transform-origin:center"><use href="#wx-vane"/></svg>`) + + `<span class="spd">${r.wind_mph}</span>`; + if (r.stale) chip.style.opacity = ".55"; + return chip; +} + +// Sample readings covering the condition set + the calm/windy collapse + stale. +const SAMPLES = [ + [{temp_f:94, condition:"clear", windy:false, wind_mph:9, wind_deg:335}, "clear · calm"], + [{temp_f:78, condition:"clear", windy:true, wind_mph:22, wind_deg:293}, "clear · windy"], + [{temp_f:61, condition:"rain", windy:false, wind_mph:7, wind_deg:120}, "rain"], + [{temp_f:58, condition:"storm", windy:true, wind_mph:31, wind_deg:200}, "storm · windy"], + [{temp_f:34, condition:"snow", windy:false, wind_mph:5, wind_deg:20}, "snow"], + [{temp_f:66, condition:"partly", windy:false, wind_mph:11, wind_deg:250}, "partly"], + [{temp_f:55, condition:"fog", windy:false, wind_mph:3, wind_deg:70}, "fog"], + [{temp_f:88, condition:"clear", windy:false, wind_mph:9, wind_deg:335, stale:true}, "stale (dimmed)"], +]; + +const host = document.getElementById("chips"); +for (const [reading, label] of SAMPLES){ + const fig = document.createElement("figure"); + fig.appendChild(renderChip(reading)); + const cap = document.createElement("figcaption"); + cap.textContent = label; + fig.appendChild(cap); + host.appendChild(fig); +} +</script> +</body> +</html> diff --git a/working/weather-kit-integration/weather-glyphs.svg b/working/weather-kit-integration/weather-glyphs.svg new file mode 100644 index 0000000..0cc3189 --- /dev/null +++ b/working/weather-kit-integration/weather-glyphs.svg @@ -0,0 +1,81 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- weather-glyphs.svg — custom weather condition glyph set for the Dupre kit. + Drawn custom (not Nerd Font / emoji) so they sit in the instrument aesthetic. + Every symbol uses currentColor, so a consumer recolours by setting `color` + on the <svg>/<use> that references it. 24x24 viewBox throughout. + + Condition ids match the `condition` field of the `weather` CLI: + clear -> wx-sun partly -> wx-partly cloud -> wx-cloud + fog -> wx-fog rain -> wx-rain snow -> wx-snow storm -> wx-storm + Plus two wind marks used by the chip's collapse rule: + wx-wind — the "windy" gust glyph (shown when wind_mph >= threshold) + wx-vane — the direction arrow (shown below threshold; rotate by wind_deg, + 0deg = points north, so the CSS transform is rotate(<wind_deg>deg)) + + Usage: + <svg width="24" height="24" style="color:#ffbe54"><use href="#wx-sun"/></svg> +--> +<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" style="position:absolute" aria-hidden="true"><defs> + + <symbol id="wx-sun" viewBox="0 0 24 24"> + <circle cx="12" cy="12" r="4.3" fill="currentColor"/> + <g stroke="currentColor" stroke-width="1.8" stroke-linecap="round"> + <line x1="12" y1="1.5" x2="12" y2="4.6"/><line x1="12" y1="19.4" x2="12" y2="22.5"/> + <line x1="1.5" y1="12" x2="4.6" y2="12"/><line x1="19.4" y1="12" x2="22.5" y2="12"/> + <line x1="4.6" y1="4.6" x2="6.8" y2="6.8"/><line x1="17.2" y1="17.2" x2="19.4" y2="19.4"/> + <line x1="4.6" y1="19.4" x2="6.8" y2="17.2"/><line x1="17.2" y1="6.8" x2="19.4" y2="4.6"/> + </g> + </symbol> + + <symbol id="wx-partly" viewBox="0 0 24 24"> + <circle cx="8.5" cy="8" r="3.2" fill="currentColor"/> + <g stroke="currentColor" stroke-width="1.5" stroke-linecap="round"> + <line x1="8.5" y1="1.5" x2="8.5" y2="3.4"/><line x1="1.7" y1="8" x2="3.6" y2="8"/> + <line x1="3.7" y1="3.2" x2="5" y2="4.5"/><line x1="13.3" y1="3.2" x2="12" y2="4.5"/> + </g> + <g fill="currentColor"><circle cx="12" cy="15" r="3.6"/><circle cx="16.5" cy="13" r="4.4"/><circle cx="20" cy="15.6" r="3.1"/><rect x="11" y="15" width="10" height="4" rx="2"/></g> + </symbol> + + <symbol id="wx-cloud" viewBox="0 0 24 24"> + <g fill="currentColor"><circle cx="7.5" cy="13" r="4"/><circle cx="12.5" cy="10.5" r="5.2"/><circle cx="17" cy="13.5" r="3.6"/><rect x="6.5" y="13" width="11.5" height="4.2" rx="2.1"/></g> + </symbol> + + <symbol id="wx-rain" viewBox="0 0 24 24"> + <g fill="currentColor"><circle cx="7.5" cy="9" r="3.6"/><circle cx="12.5" cy="7" r="4.6"/><circle cx="16.5" cy="9.6" r="3.1"/><rect x="6.5" y="9" width="10.5" height="3.8" rx="1.9"/></g> + <g stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="8" y1="15.5" x2="6.6" y2="19.5"/><line x1="12" y1="15.5" x2="10.6" y2="19.5"/><line x1="16" y1="15.5" x2="14.6" y2="19.5"/></g> + </symbol> + + <symbol id="wx-storm" viewBox="0 0 24 24"> + <g fill="currentColor"><circle cx="7.5" cy="9" r="3.6"/><circle cx="12.5" cy="7" r="4.6"/><circle cx="16.5" cy="9.6" r="3.1"/><rect x="6.5" y="9" width="10.5" height="3.8" rx="1.9"/></g> + <path d="M12.5 14 L9 19.5 h3 L10.5 23.5 L15.5 17.5 h-3 L14 14 Z" fill="currentColor"/> + </symbol> + + <symbol id="wx-snow" viewBox="0 0 24 24"> + <g fill="currentColor"><circle cx="7.5" cy="9" r="3.6"/><circle cx="12.5" cy="7" r="4.6"/><circle cx="16.5" cy="9.6" r="3.1"/><rect x="6.5" y="9" width="10.5" height="3.8" rx="1.9"/></g> + <g stroke="currentColor" stroke-width="1.5" stroke-linecap="round"> + <g transform="translate(9,17.5)"><line x1="-2" y1="0" x2="2" y2="0"/><line x1="0" y1="-2" x2="0" y2="2"/><line x1="-1.4" y1="-1.4" x2="1.4" y2="1.4"/><line x1="-1.4" y1="1.4" x2="1.4" y2="-1.4"/></g> + <g transform="translate(15,18.5)"><line x1="-2" y1="0" x2="2" y2="0"/><line x1="0" y1="-2" x2="0" y2="2"/><line x1="-1.4" y1="-1.4" x2="1.4" y2="1.4"/><line x1="-1.4" y1="1.4" x2="1.4" y2="-1.4"/></g> + </g> + </symbol> + + <symbol id="wx-fog" viewBox="0 0 24 24"> + <g stroke="currentColor" stroke-width="1.9" stroke-linecap="round"> + <line x1="3" y1="7" x2="21" y2="7"/><line x1="5" y1="11.5" x2="19" y2="11.5"/><line x1="3" y1="16" x2="18" y2="16"/><line x1="7" y1="20.5" x2="21" y2="20.5"/> + </g> + </symbol> + + <!-- windy condition: flowing gust lines, each ending in a curl --> + <symbol id="wx-wind" viewBox="0 0 24 24"> + <g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"> + <path d="M2.5 8 H13.5 a2.6 2.6 0 1 0 -2.55 -3.1"/> + <path d="M2.5 12.5 H18 a2.9 2.9 0 1 1 -2.85 3.45"/> + <path d="M2.5 17 H10.5 a2.3 2.3 0 1 1 -2.25 2.75"/> + </g> + </symbol> + + <!-- direction vane: points UP = north at 0deg; rotate by wind_deg (source bearing) --> + <symbol id="wx-vane" viewBox="0 0 24 24"> + <path d="M12 2.4 L15.6 9 L12.9 9 L12.9 21 L11.1 21 L11.1 9 L8.4 9 Z" fill="currentColor" stroke="currentColor" stroke-width=".8" stroke-linejoin="round"/> + </symbol> + +</defs></svg> |
