core+shell: the bridge, and the first module through it
One declaration does three jobs: renders the panel, carries the defaults, names
the values that come back. A module writes no DOM and reads no DOM.
Validation refuses rather than repairs -- unknown type, a renderer its type does
not have, a label that is not text, a default outside its own bounds, a when
naming a key the module never declared. A module that is wrong does not get a
best-effort panel; it does not load, and says why once.
Ordering is by declared relation and stage, never priority numbers, and a cycle
drops only the modules in it. Compatibility is by trait, never model name.
The shell hands a module { composer, values, on, shell } and nothing else: no
node, no document, no kit internals. It cannot style anything because it is
never given anything that could.
catalogue/hide.html puts one good module and five broken ones through the real
shell and shows the result: one panel, five absent, no placeholders. The lint
fixtures make the scanner prove it catches each rule, so a clean scan means
something.
First slice: modules/system/appearance. Two settings, an optional ui.js, and
clicking Light actually turns the app light.
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
// The app's regions, and the mount points each one offers.
|
||||
//
|
||||
// A region declares what it can host. Core never learns these names, and no
|
||||
// list of them exists anywhere else -- a module asks for one by name, and gets
|
||||
// it or does not.
|
||||
|
||||
import { composer } from "../composer/composer.js";
|
||||
import { offer } from "./mounts.js";
|
||||
|
||||
export function build(root) {
|
||||
const general = composer.panel.default({
|
||||
title: "Settings",
|
||||
body: composer.hint.default({ text: "Modules that announced a settings.general mount point." }),
|
||||
});
|
||||
const generation = composer.panel.default({
|
||||
title: "Generation",
|
||||
body: composer.hint.default({ text: "Modules that announced a generation.* mount point." }),
|
||||
});
|
||||
|
||||
const page = composer.splitPane.h({
|
||||
size: 46,
|
||||
label: "Settings and generation",
|
||||
panes: [general, generation],
|
||||
});
|
||||
|
||||
root.replaceChildren(page.node);
|
||||
|
||||
// The mount point IS the contract with modules; the region behind it can be
|
||||
// rearranged freely as long as the name survives.
|
||||
offer("settings.general", general.body);
|
||||
offer("generation.timing", generation.body);
|
||||
offer("generation.model", generation.body);
|
||||
|
||||
return { general, generation, page };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// What the server says exists.
|
||||
|
||||
const ENDPOINT = "/funpack/api/modules";
|
||||
|
||||
export async function fetchManifest(traits) {
|
||||
const url = traits ? `${ENDPOINT}?traits=${encodeURIComponent(traits.join(","))}` : ENDPOINT;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`${ENDPOINT} answered ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Mount points are ANNOUNCED by the regions that offer them.
|
||||
//
|
||||
// A fixed list in core would be a global list -- the one thing the architecture
|
||||
// avoids -- and it would have to be edited every time the shell grows a region.
|
||||
// Instead a region says what it can host, and a module naming something no
|
||||
// region offers is simply absent. That is "hide, don't warn" one level up.
|
||||
|
||||
const regions = new Map(); // mount point -> host element
|
||||
|
||||
export function offer(mount, host) {
|
||||
if (regions.has(mount)) {
|
||||
throw new Error(`Mount point "${mount}" is already offered by another region.`);
|
||||
}
|
||||
regions.set(mount, host);
|
||||
return () => regions.delete(mount);
|
||||
}
|
||||
|
||||
export const hostFor = (mount) => regions.get(mount) || null;
|
||||
export const offered = () => [...regions.keys()].sort();
|
||||
export const claimed = (mount) => regions.has(mount);
|
||||
|
||||
export function _reset() {
|
||||
regions.clear();
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Turning announcements into panels.
|
||||
//
|
||||
// Everything a module can reach is decided here, and it is deliberately little:
|
||||
// { composer, values, on }. No DOM node, no document, no kit internals. A module
|
||||
// cannot style anything because it is never handed anything that could.
|
||||
|
||||
import { composer } from "../composer/composer.js";
|
||||
import { renderPanel, defaultsOf } from "../composer/panel.js";
|
||||
import { hostFor, offered } from "./mounts.js";
|
||||
import * as values from "./values.js";
|
||||
import { services } from "./services.js";
|
||||
|
||||
/**
|
||||
* mountAll(manifest) -> { mounted, hidden }
|
||||
*
|
||||
* `hidden` is diagnostic only. Nothing about a hidden module reaches the screen:
|
||||
* no placeholder, no greyed row, no warning chip. A module that failed is
|
||||
* indistinguishable from one that was never installed, which is the point --
|
||||
* the alternative is a UI full of things that cannot be used.
|
||||
*/
|
||||
export async function mountAll(manifest, { load = (path) => import(path) } = {}) {
|
||||
const mounted = [];
|
||||
const hidden = [];
|
||||
|
||||
for (const spec of manifest.modules || []) {
|
||||
const host = hostFor(spec.mount);
|
||||
if (!host) {
|
||||
hidden.push({ id: spec.id, why: `no region offers "${spec.mount}" (offered: ${offered().join(", ") || "none"})` });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Values first: a module's ui.js may read them during setup, and a
|
||||
// headless run has them without any of this having happened.
|
||||
values.seed(spec.id, defaultsOf(spec));
|
||||
|
||||
let ui = null;
|
||||
if (spec.ui) ui = await load(spec.ui);
|
||||
|
||||
const panel = renderPanel(spec, {
|
||||
values: values.valuesOf(spec.id),
|
||||
onChange: (key, value) => values.set(spec.id, key, value),
|
||||
});
|
||||
|
||||
// Only now, with a complete panel in hand, does anything reach the DOM.
|
||||
host.appendChild(panel.node);
|
||||
|
||||
if (ui && typeof ui.setup === "function") {
|
||||
ui.setup({
|
||||
composer,
|
||||
values: {
|
||||
get: () => values.valuesOf(spec.id),
|
||||
set: (key, value) => values.set(spec.id, key, value),
|
||||
},
|
||||
// Scoped to this module: a module hearing about everyone else's
|
||||
// edits would have to filter, and filtering needs an id it should
|
||||
// not need to know.
|
||||
on: (fn) => values.onChange((id, key, value) => {
|
||||
if (id === spec.id) fn(key, value);
|
||||
}),
|
||||
// The app services a module may use, named explicitly. A short,
|
||||
// reviewable list beats reaching for a global, which is how a module
|
||||
// ends up depending on something nobody meant to expose.
|
||||
shell: services,
|
||||
});
|
||||
}
|
||||
|
||||
mounted.push({ id: spec.id, panel });
|
||||
} catch (err) {
|
||||
hidden.push({ id: spec.id, why: `${err.name}: ${err.message}` });
|
||||
console.warn(`[FunPack] ${spec.id} did not mount: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { mounted, hidden };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// What the app offers modules beyond the kit.
|
||||
//
|
||||
// Deliberately tiny and named one by one. The alternative -- letting a module
|
||||
// reach for a global -- means the surface is "whatever happens to be on window",
|
||||
// which nobody can review and nothing can keep stable.
|
||||
|
||||
import { applyDensity, normaliseCols, rememberCols } from "../composer/internals/density.js";
|
||||
|
||||
export const services = {
|
||||
theme: {
|
||||
set(choice) { window.ComposerTheme.apply(choice); },
|
||||
get: () => window.ComposerTheme.get(),
|
||||
resolved: () => window.ComposerTheme.resolved(),
|
||||
},
|
||||
density: {
|
||||
/** Column count for every adaptive grid on the page. */
|
||||
set(cols) {
|
||||
const n = normaliseCols(cols);
|
||||
for (const grid of document.querySelectorAll(".cx-gallery")) applyDensity(grid, n);
|
||||
rememberCols("app", n);
|
||||
return n;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
// The live value of every setting, keyed exactly as the declaration keys them.
|
||||
//
|
||||
// This is the only thing in the app that reads the UI. A module never does: it
|
||||
// declares what it needs and is handed values, so the same module works with a
|
||||
// panel on screen and with no browser at all.
|
||||
|
||||
const store = new Map(); // moduleId -> { key: value }
|
||||
const listeners = new Set();
|
||||
|
||||
export function seed(moduleId, defaults) {
|
||||
store.set(moduleId, { ...defaults });
|
||||
}
|
||||
|
||||
export function set(moduleId, key, value) {
|
||||
const current = store.get(moduleId) || {};
|
||||
current[key] = value;
|
||||
store.set(moduleId, current);
|
||||
for (const fn of listeners) {
|
||||
try { fn(moduleId, key, value); } catch { /* a listener must not break the edit */ }
|
||||
}
|
||||
}
|
||||
|
||||
export const valuesOf = (moduleId) => ({ ...(store.get(moduleId) || {}) });
|
||||
|
||||
/** Everything, as it would be sent with a generation request. */
|
||||
export const all = () => Object.fromEntries([...store].map(([id, v]) => [id, { ...v }]));
|
||||
|
||||
export function onChange(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
export function _reset() {
|
||||
store.clear();
|
||||
listeners.clear();
|
||||
}
|
||||
Reference in New Issue
Block a user