diff --git a/CHANGELOG.md b/CHANGELOG.md index bd82b2a..716e207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,16 @@ Notable changes to **pixelpets**. All art and sound are original/procedural (no ## [Unreleased] +### Settings window +- **Sections instead of one long scroll.** Everything lived in a single column: eleven cards, about 3400px of it, inside a window fixed at 560px. Reaching the pomodoro or the calendar meant scrolling past ten cards you were not looking for, with nothing to aim at and no sense of how much was left. The window is now five tabs - Pet, Play, Sound, Focus, Feeds - and the tallest of them is 1130px, with Sound fitting on screen whole. The rail is keyboard-drivable (arrows, Home/End) and carries the ARIA a tablist is supposed to. +- **The window stopped calling itself pixelcat.** 0.3.0 renamed the app, the repo and the docs, but the settings header still read "pixelcat" - at people who had just chosen a dog. +- **A dog owner is no longer told about a cat.** The window hard-coded the cat's nouns, so a dog owner read "your cat", "the cat calls you by it", "Butterfly visits - a butterfly drops by and the cat plays with it" and "Test meow", while the tray - reading the species registry - already called that same toggle "Ball to chase". Those strings now live in `pets.js` beside the tray's, so the two windows cannot describe one toggle differently, and a test fails if any dog-facing string mentions a cat. +- **Settings can be made taller.** Width stays pinned to the one-column layout, but the height is draggable now (420 minimum, 640 default) rather than frozen at 560. +- **Keyboard focus is visible again.** The toggle switches are `appearance:none`, which had taken their focus ring with them, so tabbing through the window gave no clue which switch was about to be flipped. + ### Fixes +- **The dog coat preview drew a cat.** Picking a breed relabelled everything correctly and then went on showing the previously drawn cat, because the preview read its palettes off `window.DOG_PATTERNS` - and `dog-sprite.js` is a classic script whose top-level `const`s are global *lexical* bindings that never become window properties. The lookup came back undefined and the draw bailed out early and silently, so the preview had never once rendered a dog. +- **The coat list could belong to the wrong species.** The first config and the first custom-coat list arrive as two independent IPC replies, and only the themes reply rebuilt the dropdown, so whenever it won the race a dog owner was offered the cat's coats. - **Speech bubbles hold their text.** The panel was capped at 260px but the whole message was drawn anyway, so anything past roughly 44 characters spilled white text onto the wallpaper either side of the box, where it is unreadable. Reminders and the pinned note are allowed 80 characters and calendar event titles had no cap at all, so this was the normal case rather than an edge case. Messages now wrap, an over-long word is broken instead of left hanging, a message too long to show at all is ellipsised rather than silently cut, and the panel is clamped onto the screen with its tail still pointing at the pet - which matters because the pet's default resting spot is a screen corner. - **Alerts no longer overwrite each other.** Two arriving together - two reminders set for the same minute, or a reminder landing during a calendar nudge - meant the second replaced the first on the spot, so the first could be gone milliseconds after it appeared. Only identical messages were ever suppressed, so two different alerts always collided. They now queue, and each gets its full time on screen, including while the pet is hunting or startled. - **Calendar event titles are length-capped** before they become a bubble and a Windows toast. They come from someone else's calendar and, unlike reminders and the pinned note, arrived with no bound at all. diff --git a/README.md b/README.md index 9eddd77..fcafa30 100644 --- a/README.md +++ b/README.md @@ -384,11 +384,12 @@ Ideas only; all code here is original to pixelpets. ## Custom coats -Design your own under Settings > Custom coats > "+ Add a custom coat": pick a -name, a body build (standard, slender, stocky, or fluffy), optional tabby +Design your own under Settings > Pet > Custom coats > "+ Add a custom coat": pick +a name, a body build (standard, slender, stocky, or fluffy), optional tabby stripes, and eight colours (coat, marks, white, patch, eyes, nose, inner ear, outline). Your coat shows up in the Coat dropdown and the tray menu next to the -14 built-ins. +14 built-ins. Custom coats are built from the cat's geometry, so they apply to +cats only; the Pet tab says so when a dog is selected. Custom coats live in `themes.json` in your app-data folder (`%APPDATA%/pixelcat/themes.json`) and can be hand-edited too: diff --git a/eslint.config.js b/eslint.config.js index e9c3fe3..ce46d89 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -17,6 +17,7 @@ const sharedOverlay = { // pets.js provides the species registry: PET_SPECIES: 'readonly', SPECIES_IDS: 'readonly', speciesOf: 'readonly', coatsFor: 'readonly', isSpecies: 'readonly', defaultCoatIndex: 'readonly', CAT_COATS: 'readonly', DOG_COATS: 'readonly', + SETTINGS_TEXT: 'readonly', settingsText: 'readonly', BUILDS: 'readonly', TABBY: 'readonly', PATTERN_BUILD: 'readonly', // art-frames.js provides the generated baked-pose table: ART_FRAMES: 'readonly', diff --git a/src/main.js b/src/main.js index 2e4dd45..78e1160 100644 --- a/src/main.js +++ b/src/main.js @@ -481,7 +481,11 @@ function rebuildTrayMenu() { function openSettings() { if (settingsWin && !settingsWin.isDestroyed()) { settingsWin.show(); settingsWin.focus(); return; } settingsWin = new BrowserWindow({ - width: 400, height: 560, resizable: false, fullscreenable: false, maximizable: false, + // Width is pinned (the layout is designed for one column at 400), but height is + // now draggable: the tallest section still overflows 640px on a short screen and + // a fixed window left no way out of that but scrolling. + width: 400, height: 640, minWidth: 400, maxWidth: 400, minHeight: 420, + resizable: true, fullscreenable: false, maximizable: false, title: 'pixelpets settings', skipTaskbar: false, alwaysOnTop: true, icon: path.join(__dirname, '..', 'assets', 'icon.png'), // taskbar icon for the settings window show: false, backgroundColor: '#191b22', // dark from the first paint - no white flash diff --git a/src/pets.js b/src/pets.js index 9cce112..17ccfdf 100644 --- a/src/pets.js +++ b/src/pets.js @@ -38,6 +38,13 @@ const SPECIES = { // The companion the pet plays with on its own once you step away. playNoun: 'butterfly', playToggleLabel: 'Butterfly visits', + // Vocabulary the settings window writes into SETTINGS_TEXT below. + noun: 'cat', + voice: 'meow', + voiceLine: 'meow & purr', + chase: 'pounces', + playArrival: 'drops by', + customCoatNote: 'Design your own and pick it from Coat above.', }, dog: { id: 'dog', @@ -50,9 +57,56 @@ const SPECIES = { giveChannel: 'ball', playNoun: 'ball', playToggleLabel: 'Ball to chase', + noun: 'dog', + voice: 'bark', + voiceLine: 'bark & pant', + chase: 'chases', + playArrival: 'rolls in', + // Custom coats are built from the cat's geometry (see populateCoats), so a dog + // owner needs to be told that rather than left staring at a list that never grows. + customCoatNote: 'Custom coats are cat-only for now. Switch to Cat to use one.', }, }; +// Every string in the settings window whose wording depends on the pet, keyed by +// the element id that displays it. The window used to hard-code the cat's nouns in +// its markup, so a dog owner read "the cat calls you by it" on rows the TRAY had +// already learned to call "Ball to chase" - two UIs disagreeing about one toggle. +// +// %token% is looked up on the species entry. The braces in the reminders hint are a +// DIFFERENT substitution (fillPlaceholders expands {name}/{time}/{date} at meow time) +// and must survive this pass untouched, which is why these use percent signs. +const SETTINGS_TEXT = { + petCardTitle: 'your %noun%', + nameLabel: 'Your name - the %noun% calls you by it', + coatLabel: '%coatNoun%', + huntSub: '%chase% when the mouse moves fast', + playTitle: '%playToggleLabel%', + playSub: 'a %playNoun% %playArrival% and the %noun% plays with it', + workModeSub: 'parks the %noun% in its rest corner on the taskbar & hides the %playNoun% while you work', + onTopSub: 'keep the %noun% above other windows', + soundSub: '%voiceLine% (synthesized)', + pomoSub: 'a pixel timer floats next to the %noun%; it stretches with you on breaks', + emailSub: 'the %noun% tells you when new mail arrives (IMAP)', + calSub: 'the %noun% reminds you before calendar events (.ics)', + remindersHint: 'The %noun% %voice%s your message at a set time. Placeholders: {name} {time} {date}', + pinnedNoteLabel: "Pinned note - stays above the %noun%'s head (leave empty to hide)", + coatsHint: '%customCoatNote%', + testSound: '🔊 Test %voice%', +}; + +// Resolve SETTINGS_TEXT for one species: { elementId: finalString }. An unknown +// %token% is left alone rather than blanked, so a typo shows up in the window as +// literal "%typo%" instead of silently deleting half a sentence. +function settingsText(species) { + const sp = speciesOf(species); + const out = {}; + for (const [id, tpl] of Object.entries(SETTINGS_TEXT)) { + out[id] = tpl.replace(/%(\w+)%/g, (m, k) => (typeof sp[k] === 'string' ? sp[k] : m)); + } + return out; +} + const SPECIES_IDS = Object.keys(SPECIES); const isSpecies = (s) => Object.prototype.hasOwnProperty.call(SPECIES, s); const speciesOf = (s) => SPECIES[isSpecies(s) ? s : 'cat']; @@ -62,7 +116,7 @@ const defaultCoatIndex = (s) => { return Math.max(0, sp.coats.indexOf(sp.defaultCoat)); }; -const api = { SPECIES, SPECIES_IDS, CAT_COATS, DOG_COATS, isSpecies, speciesOf, coatsFor, defaultCoatIndex }; +const api = { SPECIES, SPECIES_IDS, CAT_COATS, DOG_COATS, isSpecies, speciesOf, coatsFor, defaultCoatIndex, SETTINGS_TEXT, settingsText }; if (typeof module !== 'undefined' && module.exports) module.exports = api; else if (typeof window !== 'undefined') Object.assign(window, api, { PET_SPECIES: SPECIES }); diff --git a/src/settings-renderer.js b/src/settings-renderer.js index b0f3a6f..6b714f1 100644 --- a/src/settings-renderer.js +++ b/src/settings-renderer.js @@ -4,6 +4,46 @@ const $ = (id) => document.getElementById(id); let cfg = null; +// ---- section rail ---------------------------------------------------------- +// Everything used to live in one 3400px column inside a 560px window, so finding +// "pomodoro" meant scrolling past ten cards. Panels are shown by the `hidden` +// attribute alone; aria-selected and the roving tabindex follow it. +const TABS = Array.from(document.querySelectorAll('.tab')); +function selectTab(key, { focus = false } = {}) { + for (const t of TABS) { + const on = t.dataset.panel === key; + t.setAttribute('aria-selected', String(on)); + t.tabIndex = on ? 0 : -1; // one stop for the whole rail, arrows move within it + const panel = $('panel-' + t.dataset.panel); + if (panel) panel.hidden = !on; + if (on && focus) t.focus(); + } + window.scrollTo(0, 0); // a fresh panel starts at its top, not the last panel's offset +} +TABS.forEach((t, i) => { + t.addEventListener('click', () => selectTab(t.dataset.panel)); + t.addEventListener('keydown', (e) => { + const step = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0; + let next = null; + if (step) next = TABS[(i + step + TABS.length) % TABS.length]; + else if (e.key === 'Home') next = TABS[0]; + else if (e.key === 'End') next = TABS[TABS.length - 1]; + if (!next) return; + e.preventDefault(); + selectTab(next.dataset.panel, { focus: true }); + }); +}); +selectTab('pet'); + +// ---- per-species wording --------------------------------------------------- +// The strings live in pets.js next to the tray's, so the two windows cannot end up +// describing the same toggle differently (the settings window said "Butterfly +// visits" at dog owners while the tray already said "Ball to chase"). +function applySpeciesText() { + const text = settingsText(curSpecies()); + for (const [id, s] of Object.entries(text)) { const el = $(id); if (el) el.textContent = s; } +} + // Populate the coat dropdown from the built-in names plus any custom coats. let themes = []; // Which species the window is currently editing, and therefore which list of @@ -18,13 +58,11 @@ function populateCoats() { const base = dog ? (window.DOG_COATS || []) : (window.PATTERN_NAMES || []); const names = dog ? base.slice() : base.concat(themes.map((t) => t.name)); names.forEach((name, i) => { const o = document.createElement('option'); o.value = String(i); o.textContent = name; sel.appendChild(o); }); - const lbl = $('coatLabel'); if (lbl) lbl.textContent = dog ? 'Breed' : 'Coat'; if (cfg) sel.value = String(cfg[coatField()] || 0); else if (cur) sel.value = cur; const spSel = $('species'); if (spSel) spSel.value = curSpecies(); - // The Sound row names the voices you will actually hear, which are not the same - // two sounds for both species. - const sub = $('soundSub'); - if (sub) sub.textContent = dog ? 'bark & pant (synthesized)' : 'meow & purr (synthesized)'; + // Coat vs Breed, which voice the Sound row promises, whether custom coats apply: + // all of it is one lookup now. + applySpeciesText(); drawPreview(); } function drawPreview() { @@ -32,9 +70,14 @@ function drawPreview() { if (!P || !cv) return; const i = Number($('pattern').value) || 0; if (curSpecies() === 'dog') { - const pal = (window.DOG_PATTERNS || [])[i]; + // Bare identifiers, NOT window.*: dog-sprite.js is a classic script whose top-level + // `const`s live in the global LEXICAL scope and never become window properties (it + // has no window-export branch, only module.exports). Reading window.DOG_PATTERNS + // got undefined, so this returned early and left the cat on the canvas - the dog + // coat preview never drew once. cat-preview.js's drawDog reads them the same way. + const pal = DOG_PATTERNS[i]; if (!pal || !P.drawDog) return; - P.drawDog(cv, pal, (window.DOG_PATTERN_BUILD || [])[i]); + P.drawDog(cv, pal, DOG_PATTERN_BUILD[i]); return; } let pal, build, tabby; @@ -61,6 +104,10 @@ function render() { // Don't stomp the name field while the user is typing in it (a broadcast config // echo would otherwise overwrite it with the normalized value and jump the caret). if (document.activeElement !== $('name')) $('name').value = cfg.name || ''; + // Rebuild from the config's species before reading a coat index out of it. The + // first config and the first theme list arrive as two independent IPC replies, so + // whenever the themes won the race a dog owner got the cat's coat list. + populateCoats(); $('pattern').value = String(cfg[coatField()] || 0); $('breakMinutes').value = String(cfg.breakMinutes || 0); $('followCursor').checked = !!cfg.followCursor; diff --git a/src/settings.html b/src/settings.html index 3e810e1..069c1dd 100644 --- a/src/settings.html +++ b/src/settings.html @@ -23,13 +23,20 @@ body::-webkit-scrollbar { width: 10px; } body::-webkit-scrollbar-thumb { background: var(--line); border-radius: 6px; border: 2px solid var(--bg); } + /* Keyboard focus has to be visible on every control. The toggles below are + appearance:none, so without this a keyboard user tabbing through the window + has no idea which switch they are about to flip. */ + :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + + /* header + tabs travel together, so the tab rail needs no magic top offset */ + .top { position: sticky; top: 0; z-index: 5; } + /* sticky header bar - game-HUD feel */ .hdr { - position: sticky; top: 0; z-index: 5; display: flex; align-items: center; gap: 9px; - padding: 13px 16px; margin-bottom: 14px; + padding: 13px 16px; background: linear-gradient(180deg, #1e2027, #181a20); - border-bottom: 2px solid #0f1116; box-shadow: 0 3px 0 rgba(0,0,0,.3); + border-bottom: 1px solid #0f1116; } .hdr .paw { width: 24px; height: 24px; flex: none; display: grid; place-items: center; @@ -40,7 +47,35 @@ .hdr b i { color: var(--accent); font-style: normal; } .hdr .tag { margin-left: auto; color: var(--faint); font-size: 11px; letter-spacing: 1.5px; text-transform: uppercase; } - .wrap { padding: 0 16px; } + /* section rail - 11 cards in one scroll was a 6x blind scroll with nothing to + aim at; the rail turns "keep scrolling and hope" into one click. */ + .tabs { + display: grid; grid-template-columns: repeat(5, 1fr); + background: linear-gradient(180deg, #1a1c23, #15171d); + border-bottom: 2px solid #0f1116; box-shadow: 0 3px 0 rgba(0,0,0,.3); + } + .tab { + display: flex; flex-direction: column; align-items: center; gap: 3px; + background: transparent; color: var(--muted); + border: 0; border-bottom: 3px solid transparent; border-radius: 0; box-shadow: none; + padding: 7px 2px 6px; font: inherit; font-size: 9.5px; font-weight: 700; + letter-spacing: .7px; text-transform: uppercase; cursor: pointer; + transition: color .12s, background .12s, border-color .12s; + } + .tab .ic { font-size: 15px; line-height: 1; filter: grayscale(.6) opacity(.7); transition: filter .12s; } + .tab:hover { color: var(--fg); background: rgba(255,255,255,.045); } + .tab:active { transform: none; box-shadow: none; } /* not a chunky push-button */ + .tab:focus-visible { outline-offset: -3px; } + .tab[aria-selected="true"] { color: var(--accent); background: rgba(232,148,60,.1); border-bottom-color: var(--accent); } + .tab[aria-selected="true"] .ic { filter: none; } + + .wrap { padding: 14px 16px 0; } + + /* Visibility is the `hidden` attribute and nothing else - a parallel .on class + is one more thing that can drift out of step with aria-selected. */ + .panel { animation: panelIn .16s ease-out; } + @keyframes panelIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } } + @media (prefers-reduced-motion: reduce) { .panel { animation: none; } } /* section cards */ .card { @@ -57,9 +92,9 @@ .row { margin-bottom: 12px; } .row:last-child { margin-bottom: 0; } label.lbl { display: block; color: var(--muted); margin-bottom: 5px; font-size: 12px; } - input[type=text], input[type=number], input[type=time], select { + input[type=text], input[type=password], input[type=number], input[type=time], select { width: 100%; background: var(--bg); color: var(--fg); - border: 2px solid var(--line); border-radius: 7px; padding: 8px 9px; font: inherit; outline: none; + border: 2px solid var(--line); border-radius: 7px; padding: 8px 9px; font: inherit; transition: border-color .12s; } input:focus, select:focus { border-color: var(--accent); } @@ -75,6 +110,7 @@ border: 1px solid transparent; transition: background .12s, border-color .12s; } .check:hover { background: var(--panel2); border-color: var(--line); } + .check:has(input:focus-visible) { background: var(--panel2); border-color: var(--accent); } .check:last-child { margin-bottom: 0; } .check input { appearance: none; -webkit-appearance: none; width: 38px; height: 20px; flex: none; background: #14161c; border: 2px solid var(--line); border-radius: 12px; position: relative; cursor: pointer; transition: background .15s, border-color .15s; } @@ -84,10 +120,13 @@ .check input:checked::after { transform: translateX(18px); background: var(--accent); } .check label, .check > span.t { font-size: 12.5px; color: var(--fg); cursor: pointer; flex: 1; } .check .sub { display: block; color: var(--faint); font-size: 11px; margin-top: 1px; } + @media (prefers-reduced-motion: reduce) { + .check input, .check input::after { transition: none; } + } /* range */ input[type=range] { width: 100%; appearance: none; -webkit-appearance: none; height: 6px; border-radius: 4px; - background: #14161c; border: 1px solid var(--line); outline: none; } + background: #14161c; border: 1px solid var(--line); } input[type=range]::-webkit-slider-thumb { appearance: none; -webkit-appearance: none; width: 16px; height: 16px; border-radius: 4px; background: var(--accent); border: 2px solid #0f1116; box-shadow: 1px 1px 0 rgba(0,0,0,.4); cursor: pointer; } @@ -102,10 +141,19 @@ button.ghost { background: var(--panel2); color: var(--fg); font-weight: 600; } button.ghost:hover { background: #313542; } - .coatPreview-wrap { display: flex; justify-content: center; margin-top: 12px; } - canvas#coatPreview { display: block; image-rendering: pixelated; - background: radial-gradient(circle at 50% 35%, rgba(232,148,60,.08), rgba(255,255,255,.03)); - border: 2px solid var(--line); border-radius: 10px; } + /* coat preview - the one place the app shows you what you are actually picking, + so it gets a lit stage and a floor to sit on rather than a bare swatch box. */ + .stage { + display: grid; justify-items: center; margin-top: 12px; padding: 10px 10px 11px; + background: + radial-gradient(130px 62px at 50% 88%, rgba(232,148,60,.14), transparent 70%), + linear-gradient(180deg, #1c1f27, #171920); + border: 2px solid #14161c; border-radius: 10px; + box-shadow: inset 0 1px 0 rgba(255,255,255,.035); + } + canvas#coatPreview { display: block; image-rendering: pixelated; } + .stage .floor { width: 76%; height: 2px; border-radius: 2px; + background: linear-gradient(90deg, transparent, var(--line) 22%, var(--line) 78%, transparent); } .hint { color: var(--faint); font-size: 11px; } @@ -146,205 +194,226 @@
-