diff options
Diffstat (limited to 'js/seed')
-rw-r--r-- | js/seed/README.md | 91 | ||||
-rw-r--r-- | js/seed/index.html | 15 | ||||
-rw-r--r-- | js/seed/src/api.js | 15 | ||||
-rw-r--r-- | js/seed/src/app.js | 165 | ||||
-rw-r--r-- | js/seed/src/dev.js | 74 | ||||
-rw-r--r-- | js/seed/src/state.js | 13 | ||||
-rw-r--r-- | js/seed/src/update.js | 22 | ||||
-rw-r--r-- | js/seed/src/view.js | 62 | ||||
-rw-r--r-- | js/seed/style.css | 82 |
9 files changed, 539 insertions, 0 deletions
diff --git a/js/seed/README.md b/js/seed/README.md new file mode 100644 index 0000000..8159cb3 --- /dev/null +++ b/js/seed/README.md @@ -0,0 +1,91 @@ +# Seed: Minimal FRP/TEA Web App Starter Kit + +This is an opinionated, minimal starting point for browser-native web apps using a functional, Elm-style architecture (FRP/TEA) and only browser APIs. No frameworks, no build step, just ES modules. + +## Architecture +- **state.js**: App state definition and helpers +- **update.js**: Pure update function (handles actions/messages) +- **view.js**: Pure view functions (renders HTML as string) +- **api.js**: API fetch logic +- **app.js**: Entrypoint, main loop, event delegation + +## Pattern +- **State**: Single immutable state object +- **Update**: Pure function `(state, action) => newState` +- **View**: Pure function `(state) => html` +- **Entrypoint**: Handles events, dispatches actions, triggers re-render + +## Why? +- Simple, testable, and maintainable +- No dependencies +- Encourages functional, declarative code + +## How to Extend and Use This Template + +This template is designed to be a flexible, opinionated starting point for any browser-native app. + +### Key Files to Extend +- **src/state.js**: Define the app's state shape and any helper functions for cloning or initializing state. +- **src/update.js**: Add new action/message types and update logic. This is where you handle all state transitions. +- **src/view.js**: Build your UI as a pure function of state. Add new components or views here. +- **src/api.js**: Add or replace API calls as needed for your app's data fetching. +- **src/app.js**: Wire up events, use the generalized `render` function, and add any app-specific logic (e.g., focus management, custom event handling). + +### Using the Generalized `render` Function +The `render` function in `app.js` is designed to be reusable for any app. It takes a config object: + +```js +render({ + root, // DOM element to render into + state, // Current app state + view, // View function: (state) => html + events: [ // Array of event bindings + { selector, event, handler }, + // ... + ], + postRender // Optional: function({ root, state }) for custom logic (e.g., focus) +}); +``` + +#### Example: Adding a New Feature +Suppose you want to add a button that increments a counter: + +1. **state.js**: Add `count` to your state. +2. **update.js**: Handle an `INCREMENT` action. +3. **view.js**: Add a button and display the count. +4. **app.js**: + - Add an event handler for the button: + ```js + function handleIncrement() { + dispatch({ type: 'INCREMENT' }); + } + ``` + - Add to the `events` array: + ```js + events: [ + { selector: '#increment-btn', event: 'click', handler: handleIncrement }, + // ...other events + ] + ``` + +### Tips +- Keep all state transitions in `update.js` for predictability. +- Keep all DOM rendering in `view.js` for clarity. +- Use the `postRender` hook for accessibility or focus management. +- Add new features by extending state, update, view, and wiring up events in `app.js`. + +--- + +Inspired by the [Elm Architecture](https://guide.elm-lang.org/architecture/), but using only browser APIs and ES modules. + +--- + +## MIT License + +Copyright 2025 eli_oat + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/js/seed/index.html b/js/seed/index.html new file mode 100644 index 0000000..08bdd0e --- /dev/null +++ b/js/seed/index.html @@ -0,0 +1,15 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Starter Kit</title> + <link rel="stylesheet" href="style.css"> +</head> +<body> + <main> + <div id="app"></div> + </main> + <script type="module" src="src/app.js"></script> +</body> +</html> \ No newline at end of file diff --git a/js/seed/src/api.js b/js/seed/src/api.js new file mode 100644 index 0000000..d50c644 --- /dev/null +++ b/js/seed/src/api.js @@ -0,0 +1,15 @@ +// api.js +// API fetch logic + +/** + * Fetch a Pokémon by name from the PokéAPI + * @param {string} name + * @returns {Promise<object>} Pokémon data + */ +export async function fetchPokemon(name) { + const res = await fetch(`https://pokeapi.co/api/v2/pokemon/${encodeURIComponent(name.toLowerCase())}`); + if (!res.ok) { + throw new Error('Pokémon not found'); + } + return await res.json(); +} \ No newline at end of file diff --git a/js/seed/src/app.js b/js/seed/src/app.js new file mode 100644 index 0000000..34b4579 --- /dev/null +++ b/js/seed/src/app.js @@ -0,0 +1,165 @@ +// app.js +// Entrypoint for the app + +import { initialState, cloneState } from './state.js'; +import { update } from './update.js'; +import { view } from './view.js'; +import { fetchPokemon } from './api.js'; +import { initDevMode } from './dev.js'; + +const root = document.getElementById('app'); +let state = cloneState(initialState); +let dev; + +/** + * Entrypoint for the app. + * + * This file implements a minimal Elm-style architecture using only browser APIs and ES modules. + * - All state is immutable and updated by a pure update function. + * - The entire UI is re-rendered as a string on each state change for simplicity and predictability. + * - Event delegation is used to keep wiring minimal and functional. + * - No 3rd party code: everything is browser-native for cozy portability and clarity. + * + * Why this approach? + * - Functional, pure update/view logic is easier for me to reason about and test. + * - Re-rendering the whole UI avoids bugs from manual DOM updates and keeps state/UI in sync. + * - Minimal code and clear data flow make it easy to extend or adapt for new projects. + */ + +// Enable devMode if ?dev=1 is in the URL +/** + * devMode enables logging of all actions and state transitions for debugging. + * + * Why? This makes the app's state flow transparent, helping you understand and debug the app without extra tooling. + */ +const devMode = window.location.search.includes('dev=1'); + +/** + * Generalized render function for Elm-style apps. + * + * @param {Object} config - Render configuration + * @param {HTMLElement} config.root - Root DOM element + * @param {any} config.state - Current app state + * @param {Function} config.view - View function (state => HTML string) + * @param {Array} [config.events] - Array of { selector, event, handler } + * @param {Function} [config.postRender] - Optional function({ root, state }) for post-render logic + */ +function render({ root, state, view, events = [], postRender }) { + root.innerHTML = view(state); + events.forEach(({ selector, event, handler }) => { + const el = root.querySelector(selector); + if (el) el.addEventListener(event, handler); + }); + if (typeof postRender === 'function') { + postRender({ root, state }); + } +} + +// --- App-specific config for render --- +function postRender({ root, state }) { + const input = root.querySelector('#pokemon-query'); + const error = root.querySelector('.error'); + if (error) { + error.focus(); + } else if (input) { + input.focus(); + input.value = state.query; + input.setSelectionRange(input.value.length, input.value.length); + } +} + +function doRender() { + render({ + root, + state, + view, + events: [ + { selector: '#search-form', event: 'submit', handler: handleSubmit }, + { selector: '#pokemon-query', event: 'input', handler: handleInput }, + ], + postRender, + }); +} + +/** + * Dispatches an action to update state and re-render. + * + * Why centralize dispatch? This enforces a single source of truth for state changes, making the app predictable and easy to debug. + * + * Why log actions/state in devMode? This provides a transparent, time-travel-like view of app logic without needing any extra tooling. + */ +function dispatch(action) { + const prevState = state; + state = update(state, action); + if (devMode) { + dev.pushState(state); + console.groupCollapsed(`Action: ${action.type}`); + console.log('Payload:', action.payload); + console.log('Prev state:', prevState); + console.log('Next state:', state); + console.groupEnd(); + } + doRender(); +} + +/** + * Handles input events by dispatching an update action. + * + * Why not update state directly? All state changes go through dispatch/update for consistency and traceability. + */ +function handleInput(e) { + dispatch({ type: 'UPDATE_QUERY', payload: e.target.value }); +} + +/** + * Handles form submission, triggers async fetch, and dispatches state updates. + * + * Why handle async here? Keeps update/view pure and centralizes side-effect. + */ +async function handleSubmit(e) { + e.preventDefault(); + if (!state.query.trim()) return; + dispatch({ type: 'FETCH_START' }); + try { + const data = await fetchPokemon(state.query.trim()); + dispatch({ type: 'FETCH_SUCCESS', payload: data }); + } catch (err) { + dispatch({ type: 'FETCH_ERROR', payload: err.message }); + } +} + +// Initial render +doRender(); + +// After devMode is set +if (devMode) { + dev = initDevMode({ + getState: () => state, + setState: s => { state = s; }, + render: doRender + }); +} + +function updateHistoryInfo() { + if (!devMode || !dev) return; + dev.update(); +} + +function setHistoryPointer(idx) { + const info = dev.getHistoryInfo(); + if (idx < 1 || idx > info.length) return; + const newState = dev.setPointer(idx - 1); + if (newState) { + state = newState; + doRender(); + updateHistoryInfo(); + } +} + +function handleSliderChange(e) { + setHistoryPointer(Number(e.target.value)); +} + +function handleStepperChange(e) { + setHistoryPointer(Number(e.target.value)); +} \ No newline at end of file diff --git a/js/seed/src/dev.js b/js/seed/src/dev.js new file mode 100644 index 0000000..ee1a6e7 --- /dev/null +++ b/js/seed/src/dev.js @@ -0,0 +1,74 @@ +// devMode.js +// Minimal, single-file dev mode with scriptable console API + +/** + * Initialize dev mode: exposes a scriptable API for stepping through state history. + * @param {object} opts + * @param {function} opts.getState - returns current app state + * @param {function} opts.setState - sets app state + * @param {function} opts.render - triggers app re-render + */ +export function initDevMode({ getState, setState, render }) { + let history = []; + let pointer = -1; + let firstLoad = true; + + function pushState(state) { + if (pointer < history.length - 1) history = history.slice(0, pointer + 1); + history.push(clone(state)); + pointer = history.length - 1; + logInstructions(); + } + function goTo(idx) { + if (idx < 0 || idx >= history.length) return; + pointer = idx; + setState(clone(history[pointer])); + render(); + logInstructions(); + } + function next() { + if (pointer < history.length - 1) goTo(pointer + 1); + } + function prev() { + if (pointer > 0) goTo(pointer - 1); + } + function get() { + return history[pointer]; + } + function clone(obj) { + return JSON.parse(JSON.stringify(obj)); + } + function table(obj) { + console.table(dev.history); + } + function logInstructions() { + if (firstLoad) { + console.log('[DevMode] State history debugger'); + console.log('Usage:'); + console.log('- dev.next() // step forward'); + console.log('- dev.prev() // step backward'); + console.log('- dev.goTo(n) // jump to state n (1-based)'); + console.log('- dev.get() // get current state'); + console.log('- dev.table() // display history as a table'); + console.log('- dev.history // array of all states'); + console.log('- dev.pointer // current pointer (0-based)'); + firstLoad = false; + } + } + + // Expose API globally for console use + window.dev = { + next, + prev, + goTo, + get, + table, + get pointer() { return pointer; }, + get history() { return history.slice(); }, + }; + + // Initial state + pushState(getState()); + + return { pushState }; +} \ No newline at end of file diff --git a/js/seed/src/state.js b/js/seed/src/state.js new file mode 100644 index 0000000..d1bad17 --- /dev/null +++ b/js/seed/src/state.js @@ -0,0 +1,13 @@ +// state.js +// App state definition and helpers + +export const initialState = { + query: '', + pokemon: null, + loading: false, + error: null +}; + +export function cloneState(state) { + return JSON.parse(JSON.stringify(state)); +} \ No newline at end of file diff --git a/js/seed/src/update.js b/js/seed/src/update.js new file mode 100644 index 0000000..2f6c13b --- /dev/null +++ b/js/seed/src/update.js @@ -0,0 +1,22 @@ +// update.js +// Pure update function + +/** + * @param {object} state - Current state + * @param {object} action - { type, payload } + * @returns {object} new state + */ +export function update(state, action) { + switch (action.type) { + case 'UPDATE_QUERY': + return { ...state, query: action.payload, error: null }; + case 'FETCH_START': + return { ...state, loading: true, error: null, pokemon: null }; + case 'FETCH_SUCCESS': + return { ...state, loading: false, error: null, pokemon: action.payload }; + case 'FETCH_ERROR': + return { ...state, loading: false, error: action.payload, pokemon: null }; + default: + return state; + } +} \ No newline at end of file diff --git a/js/seed/src/view.js b/js/seed/src/view.js new file mode 100644 index 0000000..5feef6e --- /dev/null +++ b/js/seed/src/view.js @@ -0,0 +1,62 @@ +// view.js +// Pure view functions + +/** + * Pure view functions for the application. + * + * Why pure functions returning HTML strings? + * - Keeps rendering logic stateless and easy to test. + * - Ensures the UI is always a direct function of state, avoiding UI bugs from incremental DOM updates. + * - Using template literals is minimal and browser-native, with no dependencies. + * + * Why escape output? + * - Prevents XSS and ensures all user/content data is safely rendered. + * + * Why semantic/accessible HTML? + * - Ensures the app is usable for all users, including those using assistive tech, and is easy to reason about. + */ +/** + * Render the app UI as an HTML string + * @param {object} state + * @returns {string} + */ +export function view(state) { + return ` + <h1>PokéDex</h1> + <container> + <form id="search-form" autocomplete="off"> + <label for="pokemon-query">Pokémon Name (or number)</label> + <input id="pokemon-query" type="text" value="${escape(state.query)}" placeholder="e.g. pikachu" aria-label="Pokémon Name" required /> + <button type="submit" ${state.loading ? 'disabled' : ''}>${state.loading ? 'Loading...' : 'Search'}</button> + </form> + ${state.error ? `<div class="error" role="alert" tabindex="-1">${escape(state.error)}</div>` : ''} + ${state.pokemon ? renderResult(state.pokemon) : ''} + </container> + `; +} + +function renderResult(pokemon) { + return ` + <div class="result"> + <h2>${capitalize(pokemon.name)} (#${pokemon.id})</h2> + <img class="pokemon-sprite" src="${pokemon.sprites.front_default}" alt="${escape(pokemon.name)} sprite" /> + <ul> + <li>Type: <b>${pokemon.types.map(t => capitalize(escape(t.type.name))).join(', ')}</b></li> + <li>Height: <b>${pokemon.height / 10} m</b></li> + <li>Weight: <b>${pokemon.weight / 10} kg</b></li> + </ul> + </div> + `; +} + +function escape(str) { + /** + * Escapes HTML special characters to prevent XSS. + * + * Why escape here? Keeps all rendering safe by default, so no accidental injection is possible. + */ + return String(str).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +} +function capitalize(str) { + return str.charAt(0).toUpperCase() + str.slice(1); +} \ No newline at end of file diff --git a/js/seed/style.css b/js/seed/style.css new file mode 100644 index 0000000..da35a7a --- /dev/null +++ b/js/seed/style.css @@ -0,0 +1,82 @@ +body { + --color-bg: #f8f8ff; + --color-text: #222; + --color-main-bg: #fff; + --color-main-border: #222; + --color-shadow: #0001; + --color-label: #222; + --color-input-border: #222; + --color-button-bg: #222; + --color-button-text: #fff; + --color-button-disabled-bg: #888; + --color-result-border: #aaa; + --color-result-bg: #f6f6fa; + --color-error: #b30000; + + font-family: system-ui, sans-serif; + background: var(--color-bg); + color: var(--color-text); + margin: 0; + padding: 0; +} +main { + max-width: 400px; + margin: 3rem auto; + background: var(--color-main-bg); + border: 2px solid var(--color-main-border); + border-radius: 8px; + padding: 2rem 1.5rem; + box-shadow: 0 2px 8px var(--color-shadow); +} +label { + font-weight: bold; + text-transform: uppercase; + font-size: 0.98em; + margin-bottom: 0.2em; + display: block; + color: var(--color-label); +} +input[type="text"] { + width: 100%; + padding: 0.6em; + font-size: 1em; + border: 2px solid var(--color-input-border); + border-radius: 0.2em; + margin-bottom: 1em; + box-sizing: border-box; +} +button { + background: var(--color-button-bg); + color: var(--color-button-text); + border: none; + border-radius: 0.2em; + padding: 0.6em 1.2em; + font-weight: bold; + text-transform: uppercase; + cursor: pointer; + font-size: 1em; + margin-bottom: 1em; +} +button:disabled { + background: var(--color-button-disabled-bg); + cursor: not-allowed; +} +.result { + margin-top: 1.5em; + padding: 1em; + border: 1.5px solid var(--color-result-border); + border-radius: 0.3em; + background: var(--color-result-bg); +} +.pokemon-sprite { + width: 120px; + height: 120px; + object-fit: contain; + margin: 0 auto; + display: block; +} +.error { + color: var(--color-error); + font-weight: bold; + margin-top: 1em; +} \ No newline at end of file |