@jsenv/navi 0.29.156 → 0.29.158

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -421,8 +421,8 @@ const css = /* css */`@layer navi {
421
421
  --navi-typo-xl: 1.25rem;
422
422
  --navi-typo-xxl: 1.5rem;
423
423
  --navi-line-height: 1.25;
424
- line-height: var(--navi-line-height);
425
424
  font-family: ${controlDefaultFontFamily};
425
+ line-height: var(--navi-line-height);
426
426
  --navi-control-line-height: round(calc(var(--navi-line-height) * 1em),
427
427
  1px);
428
428
  --navi-color-secondary-mix: 80%;
@@ -16,6 +16,6 @@
16
16
  "import {\n getVirtualKeyboardOverlayHeight,\n subscribeVisualViewportResizeSettled,\n subscribeWindowResizeSettled,\n} from \"@jsenv/dom\";\nimport { computed, signal } from \"@preact/signals\";\n\nexport const windowWidthSignal = signal(window.innerWidth);\nexport const windowHeightSignal = signal(window.innerHeight);\n\n// Debounced (not a raw \"resize\" listener) — see window_size.js's own\n// module comment: mobile fires a transient \"resize\" when the browser's own\n// UI chrome (address bar, etc.) briefly shows/hides, and this needs to\n// settle on the exact same tick as visualViewport's own debounced resize\n// below and Popover/Dialog's own repositioning, or one flickers a moment\n// out of sync with the others.\nsubscribeWindowResizeSettled(() => {\n windowWidthSignal.value = window.innerWidth;\n windowHeightSignal.value = window.innerHeight;\n});\n\n// Visual viewport dimensions — update when the virtual keyboard opens/closes or\n// when the browser UI (address bar) shows/hides.\n// When visualViewport is not available, derived from window signals so they\n// stay live without any extra listeners.\nconst vv = window.visualViewport;\nexport const visualViewportWidthSignal = vv\n ? signal(vv.width)\n : computed(() => windowWidthSignal.value);\nexport const visualViewportHeightSignal = vv\n ? signal(vv.height)\n : computed(() => windowHeightSignal.value);\n\nif (vv) {\n const update = () => {\n visualViewportWidthSignal.value = vv.width;\n visualViewportHeightSignal.value = vv.height;\n };\n subscribeVisualViewportResizeSettled(update);\n vv.addEventListener(\"scroll\", update);\n}\n\n// The app's own screen — the visual viewport, unless the app declared a\n// narrower one with --navi-app-max-width/--navi-app-height (see\n// navi_css_vars.js, which derives --navi-app-width/--navi-app-height from\n// these in CSS). Anything escaping normal flow is sized against this rather\n// than the viewport: an app that simulates a handheld screen keeps that width\n// even for what paints on top of it.\n//\n// The declaration stays in CSS and is read back from there rather than handed\n// to navi a second time in JS — a JS copy would be the one that goes stale.\n// Read on the spot rather than cached in a signal: the only caller is a popup\n// resolving its own margin as it places itself, which already reads far more\n// of the DOM than this, and nothing then has to be invalidated when the value\n// changes.\nconst unresolvableWarned = new Set();\nconst readAppMax = (propertyName) => {\n const declared = getComputedStyle(document.documentElement)\n .getPropertyValue(propertyName)\n .trim();\n if (!declared) {\n return Infinity;\n }\n // A custom property computes to a token stream, not to a length: \"40rem\"\n // arrives here as the string \"40rem\", and parseFloat would read it as 40\n // pixels. Only px is accepted — an app declaring the screen it simulates has\n // a pixel number to give, and resolving arbitrary lengths would mean laying\n // out a probe element on every read.\n const inPixels = /^([0-9.]+)px$/.exec(declared);\n if (!inPixels) {\n if (!unresolvableWarned.has(propertyName)) {\n unresolvableWarned.add(propertyName);\n // Not silently wrong, just partially applied: CSS still caps the popup's\n // size with the declared length, only the margin it keeps with the edges\n // falls back to a share of the viewport (the pre-token behavior).\n console.warn(\n `${propertyName}=\"${declared}\" must be a length in pixels (\"600px\"). Until then popups keep viewport-sized margins.`,\n );\n }\n return Infinity;\n }\n return parseFloat(inPixels[1]);\n};\nexport const getAppWidth = () =>\n Math.min(visualViewportWidthSignal.value, readAppMax(\"--navi-app-max-width\"));\n// Minus what the keyboard covers, so this stays the JS reading of the very\n// same rectangle --navi-app-height describes in CSS (see safe_area.js's own\n// --navi-keyboard-inset-bottom). Zero unless the app opted into the keyboard\n// overlaying its content — otherwise the shrinking visual viewport above has\n// already accounted for it, and subtracting again would count it twice.\nexport const getAppHeight = () =>\n Math.max(\n 0,\n Math.min(\n visualViewportHeightSignal.value,\n readAppMax(\"--navi-app-max-height\"),\n ) - getVirtualKeyboardOverlayHeight(),\n );\n\n// Whether the primary input is a finger rather than a mouse. A pointer type is\n// not a size: a narrow desktop window is still a mouse, and a large tablet is\n// still a finger — so anything sized for the on-screen keyboard must key off\n// this, never off windowWidthSignal. Thumb reach needs both, which is what\n// smallTouchScreenSignal below answers.\nconst coarsePointerQuery = window.matchMedia\n ? window.matchMedia(\"(pointer: coarse)\")\n : null;\nexport const coarsePointerSignal = signal(\n coarsePointerQuery ? coarsePointerQuery.matches : false,\n);\nif (coarsePointerQuery) {\n coarsePointerQuery.addEventListener(\"change\", () => {\n coarsePointerSignal.value = coarsePointerQuery.matches;\n });\n}\n\n// Whether the screen is one a bottom sheet actually suits: a finger *and* a\n// screen whose bottom edge stays where the thumb already is. Touch alone is not\n// enough — a tall touch screen (a tablet, a kiosk panel) docks a sheet a whole\n// screen away from where the finger just tapped, which is worse than the\n// centered box it replaced.\n//\n// A phone is recognized by its SHAPE, not by a box of maximum dimensions: what\n// makes the bottom edge reachable is holding a narrow slab, and phones keep\n// growing along their long side (20:9, 21:9) while staying just as narrow. So\n// each orientation is answered on the short side plus the elongation:\n// - upright: narrow enough to be held in one hand, and taller than it is wide.\n// Its height is deliberately unbounded — a very tall narrow screen is the\n// case a bottom sheet is most for, not the case to exclude.\n// - on its side: short enough that the bottom edge is a thumb away whatever the\n// width, and wider than it is tall.\n// A tablet fails both: it is too wide upright, and too tall on its side — the\n// smallest one already starts around 740 CSS px on its short side, and the\n// bound below leaves that gap deliberately wide rather than cutting close to\n// the largest phone.\n//\n// Read off window, not visualViewport: the virtual keyboard shrinks the visual\n// viewport while the user types, and a dialog must not undock mid-interaction\n// because a keyboard opened under it.\nconst HANDHELD_MAX_SHORT_SIDE = 600;\n// Enough elongation to tell a slab from a square-ish panel; a phone is well\n// past it (1.7 and up) in either orientation.\nconst HANDHELD_MIN_RATIO = 1.2;\nexport const smallTouchScreenSignal = computed(() => {\n if (!coarsePointerSignal.value) {\n return false;\n }\n const width = windowWidthSignal.value;\n const height = windowHeightSignal.value;\n if (width <= HANDHELD_MAX_SHORT_SIDE) {\n return height >= width * HANDHELD_MIN_RATIO;\n }\n if (height <= HANDHELD_MAX_SHORT_SIDE) {\n return width >= height * HANDHELD_MIN_RATIO;\n }\n return false;\n});\n",
17
17
  "/**\n * The part of the window an app actually has, in two levels.\n *\n * Two, and not one, for a reason worth stating up front: a fixed bar is one of\n * the things that reduce the free region, so it cannot ALSO be placed against\n * that region — it would push itself off the edge it is pinned to. What is\n * anchored and what is anchored-inside are two different rectangles.\n *\n * 1. `--navi-app-inset-{top,right,bottom,left}` — from the window's edges to\n * the app's own rectangle. Whatever is pinned to an edge (a fixed bar, a\n * side panel, a popup aimed at a corner) is pinned to THAT, so an app\n * pretending to be a 600px handheld inside a 1500px window stays one\n * rectangle instead of a column with its furniture spread across the glass.\n *\n * 2. `--navi-safe-area-inset-{top,right,bottom,left}` — from the window's edges\n * to the band left free INSIDE that rectangle. Whatever flows, scrolls, or\n * gets painted keeps to it.\n *\n * Level 2 is a sum, and the contract for taking part in it is only \"publish\n * what you take on one edge\": the device's own notch (`env(safe-area-inset-*)`,\n * which is the browser's version of this very idea), the fixed bars\n * (fixed_bar_space.js), and anything an app adds. That is the point of naming\n * it at all — a component that must stay clear of what covers the screen reads\n * ONE set of numbers, and never has to learn what is covering it.\n *\n * `max()` between the notch and the bars rather than a sum: a bar pinned to an\n * edge already reaches under the notch and counts it in its own size (see\n * fixed_bar.jsx), so adding both would reserve it twice.\n *\n * Sizes only, not placement, for the time being — see \"Current limitations\" in\n * docs/css_architecture.md.\n */\n\nconst SAFE_AREA_CSS = /* css */ `\n /* Declared as lengths so that they COMPUTE to one: an unregistered custom\n property keeps the calc() it was written as, and the sum below is then a\n string no one can read back. Reading it off the computed style — which a\n route transition does, to keep the band the page being left had (see\n nav/transition_window.js) — only works for a registered property. */\n @property --navi-safe-area-inset-top {\n syntax: \"<length>\";\n inherits: true;\n initial-value: 0px;\n }\n @property --navi-safe-area-inset-right {\n syntax: \"<length>\";\n inherits: true;\n initial-value: 0px;\n }\n @property --navi-safe-area-inset-bottom {\n syntax: \"<length>\";\n inherits: true;\n initial-value: 0px;\n }\n @property --navi-safe-area-inset-left {\n syntax: \"<length>\";\n inherits: true;\n initial-value: 0px;\n }\n\n @layer navi {\n /* Layered whole, rules included: the two rules below are offers, not\n structure. [data-navi-safe-area] is an attribute the app puts on its own\n scroller, so the app's own padding on that element has to win over what\n navi suggests for it. */\n :root {\n /* The room each kind of furniture takes, declared here at zero and\n written by whoever takes it. A slot rather than a value: the sum below\n has to be readable whether or not the app ever mounts a fixed bar. */\n --navi-fixed-bar-space-top: 0px;\n --navi-fixed-bar-space-right: 0px;\n --navi-fixed-bar-space-bottom: 0px;\n --navi-fixed-bar-space-left: 0px;\n\n /* What the on-screen keyboard covers — and ONLY where it overlays the\n content rather than shrinking the viewport, which is navi's default\n wherever the browser has the VirtualKeyboard API (see\n layout/virtual_keyboard.js). Zero on Firefox/Safari, which have no\n such API, and zero for an app that called\n disableVirtualKeyboardOverlay(): both get a keyboard that shrinks the\n visual viewport instead, which --navi-vvh already tracks. Reading\n env() rather than a JS-written value keeps it live: the keyboard\n slides in over several frames and this follows it without a\n listener. */\n --navi-keyboard-inset-bottom: env(keyboard-inset-height, 0px);\n\n /* Level 1. Centered bands, so that declaring one ceiling\n (--navi-app-max-width) is all an app has to do to be a narrow screen in\n a wide window; an app that wants them uneven writes these directly. */\n --navi-app-inset-top: max(\n 0px,\n (var(--navi-vvh) - var(--navi-app-max-height, var(--navi-vvh))) / 2\n );\n /* The keyboard on top of the band, and on this edge only: it eats into\n the app's own rectangle exactly like a viewport that shrank, which is\n what makes both paths end up at the same --navi-app-height (and so at\n the same dialog/popover ceiling). Not part of the centering, hence\n added here rather than folded into --navi-app-inset-top: a keyboard\n takes the bottom, it doesn't re-center anything. */\n --navi-app-inset-bottom: calc(\n var(--navi-app-inset-top) + var(--navi-keyboard-inset-bottom)\n );\n --navi-app-inset-left: max(\n 0px,\n (var(--navi-vvw) - var(--navi-app-max-width, var(--navi-vvw))) / 2\n );\n --navi-app-inset-right: var(--navi-app-inset-left);\n\n /* Level 2. */\n --navi-safe-area-inset-top: calc(\n var(--navi-app-inset-top) +\n max(env(safe-area-inset-top), var(--navi-fixed-bar-space-top))\n );\n --navi-safe-area-inset-right: calc(\n var(--navi-app-inset-right) +\n max(env(safe-area-inset-right), var(--navi-fixed-bar-space-right))\n );\n --navi-safe-area-inset-bottom: calc(\n var(--navi-app-inset-bottom) +\n max(env(safe-area-inset-bottom), var(--navi-fixed-bar-space-bottom))\n );\n --navi-safe-area-inset-left: calc(\n var(--navi-app-inset-left) +\n max(env(safe-area-inset-left), var(--navi-fixed-bar-space-left))\n );\n\n /* The document is the scrollport in the common case, and something the\n browser scrolls to — an anchor, a focused field, a restored position —\n landing under a bar is never what anyone wants. */\n scroll-padding-top: var(--navi-safe-area-inset-top);\n scroll-padding-right: var(--navi-safe-area-inset-right);\n scroll-padding-bottom: var(--navi-safe-area-inset-bottom);\n scroll-padding-left: var(--navi-safe-area-inset-left);\n }\n\n /* Put this on whatever scrolls under the furniture.\n\n There are TWO rooms to give back, and forgetting the second one is the\n classic bug:\n\n - padding, so the end of the content can be scrolled out from under it.\n Without it the last screenful stays covered, unreachable.\n - scroll-padding, so anything the browser scrolls TO lands in front of it\n rather than under. The padding above does not help here: it moves the\n content, not the place the browser scrolls the target to.\n\n Marked by the app rather than picked by navi: which element scrolls is\n the app's business, and an app with more than one would have to fight a\n component that chose for it. An app is free to read the variables itself\n instead. */\n [data-navi-safe-area] {\n padding-top: var(--navi-safe-area-inset-top);\n padding-right: var(--navi-safe-area-inset-right);\n padding-bottom: var(--navi-safe-area-inset-bottom);\n padding-left: var(--navi-safe-area-inset-left);\n\n scroll-padding-top: var(--navi-safe-area-inset-top);\n scroll-padding-right: var(--navi-safe-area-inset-right);\n scroll-padding-bottom: var(--navi-safe-area-inset-bottom);\n scroll-padding-left: var(--navi-safe-area-inset-left);\n }\n }\n`;\nimport.meta.css = SAFE_AREA_CSS;\n",
18
18
  "/**\n * navi's stance on the on-screen keyboard: it overlays the app rather than\n * resizing the viewport, wherever the browser can be told so (the\n * VirtualKeyboard API — Chromium only). See virtual_keyboard.js in @jsenv/dom\n * for what that trades away and what it gives back.\n *\n * Turned on rather than offered, because navi already sizes everything that\n * escapes normal flow against the app's own rectangle rather than against the\n * window (--navi-app-width/height, see navi_css_vars.js), and\n * --navi-keyboard-inset-bottom (safe_area.js) puts the keyboard into exactly\n * that rectangle. So the two mechanisms reach the same numbers here, and the\n * overlay reaches them without reflowing the page underneath — a resizing\n * viewport is a resize of everything, fired transiently every time focus goes\n * from one input to the next.\n *\n * An app that built its own layout around the viewport shrinking can say so\n * with disableVirtualKeyboardOverlay(), and gets the behavior Firefox and\n * Safari give it anyway.\n *\n * The one thing it hands back to the app: scrolling the focused field into\n * view. A viewport that shrinks makes the browser do it; a keyboard that\n * merely paints over the page leaves whatever is under it under it. navi\n * answers that for what it places itself — a popup is sized and positioned\n * against the app rectangle the keyboard was just subtracted from — and for\n * anything marked [data-navi-safe-area], whose scroll-padding-bottom counts\n * the keyboard in (safe_area.js). A field in a scroller the app never marked\n * is the app's own to handle.\n */\n\nimport { setVirtualKeyboardOverlaysContent } from \"@jsenv/dom\";\n\nsetVirtualKeyboardOverlaysContent(true);\n\nexport const disableVirtualKeyboardOverlay = () => {\n setVirtualKeyboardOverlaysContent(false);\n};\n",
19
- "/**\n * Regroup CSS vars that makes sense to share across all navi components.\n */\nimport { effect } from \"@preact/signals\";\n\nimport {\n visualViewportHeightSignal,\n visualViewportWidthSignal,\n} from \"./layout/responsive.js\";\nimport \"./layout/safe_area.js\";\n// Side-effect import: turns the on-screen keyboard into something that\n// overlays the app instead of resizing the viewport, which is navi's default\n// (see that module for why, and safe_area.js's own\n// --navi-keyboard-inset-bottom for what then reads the geometry). Here rather\n// than in each component: it is one decision about the whole window, and this\n// is the module that already makes those.\nimport \"./layout/virtual_keyboard.js\";\n\nconst button = document.createElement(\"button\");\nbutton.style.display = \"none\";\ndocument.body.appendChild(button);\nconst computedStyle = getComputedStyle(button);\nconst controlDefaultFontFamily = computedStyle.fontFamily;\nconst controlDefaultFontSize = computedStyle.fontSize;\ndocument.body.removeChild(button);\n\nconst css = /* css */ `\n @layer navi {\n :root {\n /* Overridden at runtime with precise VisualViewport pixel values so that dvw/dvh \n (which don't track the virtual keyboard dimensions) are never used in practice on supported browsers. */\n --navi-vvw: 100dvw;\n --navi-vvh: 100dvh;\n\n /* What navi treats as \"the screen\" when it sizes something that escapes\n normal flow (a dialog in the top layer, a popover, anything built on\n them): the app's own rectangle, which is the visual viewport minus the\n bands an app asks for. An app that never spans the whole window says so\n ONCE, without ever naming a component:\n\n :root {\n --navi-app-max-width: 600px;\n }\n\n In pixels — see readAppMax in layout/responsive.js for why.\n\n Typically an app simulating a handheld screen: a column centered in a\n wide window with bands on the sides. A dialog is in the top layer, so\n it answers to the viewport, not to that column — left alone it would\n paint 1500px of modal over a 600px app. Declaring the ceiling here is\n what makes the app's own width apply everywhere, including on top.\n\n It stays a ceiling and nothing else: on a screen narrower than the app\n it never binds, so popups keep shrinking with the phone, and the gap\n each popup keeps with the edges (marginWithContainer) is subtracted\n from it as before. A single popup that genuinely needs more can still\n raise its own maxWidth/maxHeight prop.\n\n Read from the insets rather than as a min() of its own so that the\n width and the placement come from ONE description of where the app is\n (see layout/safe_area.js). Placement does not follow yet everywhere —\n see \"Current limitations\" in docs/css_architecture.md. */\n --navi-app-width: calc(\n var(--navi-vvw) - var(--navi-app-inset-left) - var(\n --navi-app-inset-right\n )\n );\n --navi-app-height: calc(\n var(--navi-vvh) - var(--navi-app-inset-top) - var(\n --navi-app-inset-bottom\n )\n );\n\n --navi-focus-outline-width: 2px;\n --navi-focus-outline-color: light-dark(#4476ff, #3b82f6);\n --navi-loader-color: light-dark(#355fcc, #3b82f6);\n /* The grey rectangle a mobile browser flashes under a finger: we draw\n what a press does ourselves (background, ring, chevron), and the\n browser's rectangle is a square painted over rounded corners.\n Declared here alone because the property is inherited: :root covers\n every element, in and out of navi, so nothing has to remember it.\n Set the token to a color to get the flash back, on the whole app or\n on one subtree. */\n -webkit-tap-highlight-color: var(--navi-tap-highlight-color);\n --navi-tap-highlight-color: transparent;\n\n /* A face of their own for the controls, and nothing else on the page.\n Undeclared here on purpose: controls write in inherit, so what a\n container says reaches the button, the field and the picker inside it,\n and a value on :root would resolve here and wall that off again. Set\n it to give the controls their own face, on the whole app or on one\n subtree:\n\n :root {\n --navi-control-font-family: system-ui;\n }\n */\n --navi-control-font-size: ${controlDefaultFontSize};\n --navi-control-border-radius: 2px;\n /* A checkbox is only ~1em wide: applying a control radius meant for\n buttons/inputs (say 8px) would turn it into a circle and make it read\n as a radio. So it follows the control radius but capped at a quarter\n of its own size. Override this token alone to opt out of the cap. */\n --navi-checkbox-border-radius: min(\n var(--navi-control-border-radius),\n 0.25em\n );\n /* The color a control uses to say \"this one is on\": a checked checkbox,\n a checked radio, an enabled switch. Kept apart from --navi-accent-color\n (the brand color for CTA and selection) because a control that is on\n must stay readable as a control, which usually means a brighter, more\n saturated color than a brand accent.\n The light value matches the browser's own accent so a navi control and\n a native one can sit side by side without reading as two blues. */\n --navi-control-accent-color: light-dark(rgb(24, 117, 255), #3b82f6);\n --navi-control-border-width: 1px;\n --navi-control-border-color: light-dark(#767676, #8e8e93);\n --navi-control-padding-x-default: 2px;\n --navi-control-padding-y-default: 1px;\n /* Global padding defaults — override these to change all button paddings. */\n /* Use --button-padding, --button-padding-x, --button-padding-y for per-button overrides. */\n --navi-button-padding-x-default: 6px;\n --navi-button-padding-y-default: 1px;\n /* Read by Button, and by whatever is drawn as one (a picker under\n variant=\"button\"), so the two keep the same surface. */\n --navi-button-background-color: light-dark(#f3f4f6, #2d3748);\n\n /* What a callout says about its message, as a color — and what draws\n as one outside the callout too (the icon a picker in callout mode\n presses, see CalloutStatusIcon). */\n --navi-callout-success-color: #4caf50;\n --navi-callout-info-color: #2196f3;\n --navi-callout-warning-color: #ff9800;\n --navi-callout-error-color: #f44336;\n /* No status: the message is an aside, said without an icon and framed\n like a control rather than like a warning. */\n --navi-callout-neutral-color: var(--navi-control-border-color);\n /* For list item we need slightly more padding to be able to see radio/checkbox outline */\n --navi-list-item-padding-x-default: 4px;\n --navi-list-item-padding-y-default: 1px;\n /* default */\n --navi-picker-padding-x-default: var(--navi-control-padding-x-default);\n --navi-picker-padding-y-default: var(--navi-control-padding-y-default);\n\n --navi-popup-border-radius: 8px;\n --navi-popup-border-color: light-dark(#d0d0d0, #3b3b3b);\n --navi-popup-box-shadow:\n 0 4px 8px rgba(0, 0, 0, 0.08), 0 12px 40px rgba(0, 0, 0, 0.22);\n /* A popup's paper is the same paper as everything else's: derived from\n the surface token so one override themes fields, layouts and popups\n together. Still its own token, so popups alone can be re-papered. */\n --navi-popup-background-color: var(--navi-surface-color);\n /* The ink written on that paper. A popup declares both on itself (see\n dialog.jsx / popover.jsx): it is a new surface, so it does not write\n in whatever the container it was declared in writes in. */\n --navi-popup-color: var(--navi-surface-text-color);\n --navi-backdrop-close-background: rgba(0, 0, 0, 0.08);\n /* backdropVariant=\"discrete\": the popup still catches every outside\n click, it just stops announcing that it did. For an affordance one\n reaches past rather than through — the dim is there to mark the layer,\n not to push the page behind it away. */\n --navi-backdrop-discrete-background: rgba(0, 0, 0, 0.02);\n /* \"capture\" means the rest of the page is fully non-interactive —\n blurred, not just dimmed, so it reads as clearly secondary and\n pulls visual focus onto the popover's own content. */\n --navi-backdrop-capture-background: rgb(255 255 255 / 0.08);\n --navi-backdrop-capture-backdrop-filter: blur(30px) saturate(180%);\n\n /* Link colors. They live here rather than only on .navi_link because a\n var declared on the element itself always beats the same var inherited\n from an ancestor: a page setting --link-color-pressed on :root would\n never reach a link. These :root tokens are the theme-level surface;\n --link-color-* stays the per-link (or per-subtree) override. */\n --navi-link-color: rgb(0, 0, 238);\n /* --navi-link-color-visited is intentionally left undefined: by default a\n visited link is derived from whatever --link-color ended up being on\n the link itself (see link.jsx), which a :root token cannot see. Set it\n here from an app to pin one visited color for every link. */\n --navi-link-color-pressed: red;\n --navi-link-current-indicator-color: rgb(205, 52, 37);\n\n --navi-selection-border-color: #0078d4;\n --navi-selection-background-color: #eaf1fd;\n /* Accent color — used for call-to-action buttons and selected list items.\n Override this single variable to apply a consistent brand color across\n all components that need to stand out. */\n --navi-accent-color: rgb(3, 30, 60);\n /* The surface a control's content sits on — the \"paper\" behind the text.\n Fields use it whenever they need a solid background (their default\n background, a transparent field being edited, …). */\n --navi-surface-color: light-dark(#ffffff, #1c1c1e);\n /* What is written on that paper. The browser's own text color rather\n than a literal, so it follows color-scheme the way the paper does. */\n --navi-surface-text-color: CanvasText;\n /* The line that separates two regions of one surface — a scrolling area's\n header from what scrolls under it, for instance. Not a border: the\n separation belongs to the layout, not to the box that draws it. */\n --navi-separator-color-default: #d1d9e0;\n --navi-color-white: white;\n --navi-color-dark: rgb(55, 60, 69);\n\n --navi-info-color-light: #eaf6fc;\n --navi-info-color: #376cc2;\n --navi-success-color-light: #ecf9ef;\n --navi-success-color: #50c464;\n --navi-warning-color-light: #fdf6e3;\n --navi-warning-color: #f19c05;\n --navi-error-color-light: #fcebed;\n --navi-error-color: #eb364b;\n\n --navi-xxs: 0.125em; /* = 2px at 16px base */\n --navi-xs: 0.25em; /* = 4px at 16px base */\n --navi-s: 0.5em; /* = 8px at 16px base */\n --navi-m: 1em; /* = 16px at 16px base (base font size) */\n --navi-l: 1.5em; /* = 24px at 16px base */\n --navi-xl: 2em; /* = 32px at 16px base */\n --navi-xxl: 3em; /* 48px at 16px base */\n\n --navi-typo-xxs: 0.625rem; /* 10px at 16px base */\n --navi-typo-xs: 0.75rem; /* 12px at 16px base */\n --navi-typo-s: 0.875rem; /* 14px at 16px base */\n --navi-typo-m: 1rem; /* 16px at 16px base (base font size) */\n --navi-typo-l: 1.125rem; /* 18px at 16px base */\n --navi-typo-xl: 1.25rem; /* 20px at 16px base */\n --navi-typo-xxl: 1.5rem; /* 24px at 16px base */\n\n /* The line every text is written on, controls included. A number\n rather than \"normal\", because of the emoji: its box is taller than a\n letter's, so under \"normal\" the one line carrying one stands taller\n than the lines around it, and under a tighter line its top is cut.\n 1.25 is where neither happens — 1 clips the glyph, 1.5 spaces the rows\n out more than reading them asks for — which also makes it the floor\n for an app that displays what people typed.\n The document is written on it; the components that come with a line of\n their own from the browser (Button, Input, Textarea, Select) are handed\n it by name. One number everywhere is what keeps a value on the same\n line whether it is typed in a field or drawn in the page.\n See docs/typography.md. */\n --navi-line-height: 1.25;\n /* Layered: the document's line is what navi writes on when the app says\n nothing, so the app's own body rule wins. */\n line-height: var(--navi-line-height);\n /* The face everything is written in, controls included — they inherit\n it rather than carrying one of their own, so an app names its typeface\n once, wherever it wants it to start: on the document, on a section, on\n a popup opened with font=\"…\". The browser's own control face rather\n than the document's default, so that a page saying nothing gets its\n text and its buttons in one face instead of two.\n Layered like the line above, so the app's own rule wins. */\n font-family: ${controlDefaultFontFamily};\n /* The same line for a control, snapped to the pixel. The browser lays a\n line out at its exact height but paints the glyph on a pixel row: at\n the default control size (13.333px) the line is 16.666px, and the\n two-thirds that do not fit go entirely under the glyph, which then\n sits a pixel above the middle of its field. A whole number of pixels\n has no remainder to put anywhere. Written with em so it resolves on\n the control that uses it, at that control's own size. */\n --navi-control-line-height: round(\n calc(var(--navi-line-height) * 1em),\n 1px\n );\n\n /* Color keywords.\n primary: the ink of the paper, at full strength. An absolute\n rather than currentColor: it is what brings a run back\n to plain text inside a muted one (a value in a secondary\n label). Themed through --navi-surface-text-color.\n secondary: supporting text, captions, less important labels\n emphasis: reinforce meaning, make content stand out more\n discrete: unobtrusive elements that shouldn't compete for attention\n hint: barely-there color, watermarks, ghost placeholders\n The last four mix currentColor toward transparent or black, so they\n follow whatever ink a container writes in: a dark card sets color and\n nothing else. The share of ink in each is the theme's knob, a -mix\n token read wherever the formulas are declared — here and on each\n surface that re-declares them (rules below) — so a ratio set on :root\n reaches the page and its popups alike. Set on a container it reaches\n none of that container's own text, a var() resolving where its custom\n property is declared; a container that wants one keyword otherwise\n for ITS paper (a white at 88% where 80% of white reads too faint on a\n colored resin) pins the keyword itself, and that value stops at the\n next surface. */\n --navi-color-secondary-mix: 80%;\n --navi-color-emphasis-mix: 50%;\n --navi-color-discrete-mix: 60%;\n --navi-color-hint-mix: 25%;\n --navi-color-primary: var(--navi-surface-text-color);\n\n /* What a control shows while it holds nothing: the ::placeholder of an\n input, the empty value slot of a picker. One place for the whole app:\n\n :root {\n --navi-placeholder-color: #8a94a6;\n --navi-placeholder-font-style: italic;\n }\n */\n --navi-placeholder-color: var(--navi-color-discrete);\n --navi-placeholder-font-style: normal;\n }\n\n /* A surface is a new paper: it writes in an ink of its own, and the\n keywords below are computed against that ink rather than the\n container's. A popup writes in --navi-popup-color. */\n .navi_popover,\n .navi_dialog {\n --navi-color-primary: var(--navi-popup-color);\n }\n /* A callout writes in the UA's own ink: callout.js sets color: revert on\n a [popover] element, which the UA styles CanvasText. */\n .navi_callout {\n --navi-color-primary: CanvasText;\n }\n\n /* The keywords derived from the ink, declared on every paper — :root, and\n each surface that is a new one — so what a container pinned for its own\n paper stops at the surface. The -mix ratios (see :root) are read where\n this is declared, and each paper resolves currentColor as its own. On\n the element itself, so a surface beats the container's value whatever\n the layer; an app that wants a surface to keep its container's ink says\n so on the surface, unlayered, and wins in turn. */\n :root,\n .navi_popover,\n .navi_dialog,\n .navi_callout {\n --navi-color-secondary: color-mix(\n in srgb,\n currentColor var(--navi-color-secondary-mix),\n transparent\n );\n --navi-color-emphasis: color-mix(\n in srgb,\n currentColor var(--navi-color-emphasis-mix),\n black\n );\n --navi-color-discrete: color-mix(\n in srgb,\n currentColor var(--navi-color-discrete-mix),\n transparent\n );\n --navi-color-hint: color-mix(\n in srgb,\n currentColor var(--navi-color-hint-mix),\n transparent\n );\n }\n }\n\n /* Hidden appearance */\n input[navi-visually-hidden],\n button[navi-visually-hidden],\n div[navi-visually-hidden] {\n position: absolute;\n top: 0;\n left: 0;\n z-index: -1;\n /* Important to take full size so that scrollIntoView work as expected */\n width: 100%;\n height: 100%;\n margin: 0;\n padding: 0;\n white-space: nowrap;\n border: 0;\n clip-path: inset(50%);\n appearance: none;\n overflow: hidden;\n\n &[navi-debug] {\n position: static;\n width: auto;\n height: auto;\n margin: 0;\n clip-path: none;\n appearance: auto;\n }\n }\n`;\nimport.meta.css = css;\n\neffect(() => {\n document.documentElement.style.setProperty(\n \"--navi-vvw\",\n `${visualViewportWidthSignal.value}px`,\n );\n document.documentElement.style.setProperty(\n \"--navi-vvh\",\n `${visualViewportHeightSignal.value}px`,\n );\n});\n"
19
+ "/**\n * Regroup CSS vars that makes sense to share across all navi components.\n */\nimport { effect } from \"@preact/signals\";\n\nimport {\n visualViewportHeightSignal,\n visualViewportWidthSignal,\n} from \"./layout/responsive.js\";\nimport \"./layout/safe_area.js\";\n// Side-effect import: turns the on-screen keyboard into something that\n// overlays the app instead of resizing the viewport, which is navi's default\n// (see that module for why, and safe_area.js's own\n// --navi-keyboard-inset-bottom for what then reads the geometry). Here rather\n// than in each component: it is one decision about the whole window, and this\n// is the module that already makes those.\nimport \"./layout/virtual_keyboard.js\";\n\nconst button = document.createElement(\"button\");\nbutton.style.display = \"none\";\ndocument.body.appendChild(button);\nconst computedStyle = getComputedStyle(button);\nconst controlDefaultFontFamily = computedStyle.fontFamily;\nconst controlDefaultFontSize = computedStyle.fontSize;\ndocument.body.removeChild(button);\n\nconst css = /* css */ `\n @layer navi {\n :root {\n /* Overridden at runtime with precise VisualViewport pixel values so that dvw/dvh \n (which don't track the virtual keyboard dimensions) are never used in practice on supported browsers. */\n --navi-vvw: 100dvw;\n --navi-vvh: 100dvh;\n\n /* What navi treats as \"the screen\" when it sizes something that escapes\n normal flow (a dialog in the top layer, a popover, anything built on\n them): the app's own rectangle, which is the visual viewport minus the\n bands an app asks for. An app that never spans the whole window says so\n ONCE, without ever naming a component:\n\n :root {\n --navi-app-max-width: 600px;\n }\n\n In pixels — see readAppMax in layout/responsive.js for why.\n\n Typically an app simulating a handheld screen: a column centered in a\n wide window with bands on the sides. A dialog is in the top layer, so\n it answers to the viewport, not to that column — left alone it would\n paint 1500px of modal over a 600px app. Declaring the ceiling here is\n what makes the app's own width apply everywhere, including on top.\n\n It stays a ceiling and nothing else: on a screen narrower than the app\n it never binds, so popups keep shrinking with the phone, and the gap\n each popup keeps with the edges (marginWithContainer) is subtracted\n from it as before. A single popup that genuinely needs more can still\n raise its own maxWidth/maxHeight prop.\n\n Read from the insets rather than as a min() of its own so that the\n width and the placement come from ONE description of where the app is\n (see layout/safe_area.js). Placement does not follow yet everywhere —\n see \"Current limitations\" in docs/css_architecture.md. */\n --navi-app-width: calc(\n var(--navi-vvw) - var(--navi-app-inset-left) - var(\n --navi-app-inset-right\n )\n );\n --navi-app-height: calc(\n var(--navi-vvh) - var(--navi-app-inset-top) - var(\n --navi-app-inset-bottom\n )\n );\n\n --navi-focus-outline-width: 2px;\n --navi-focus-outline-color: light-dark(#4476ff, #3b82f6);\n --navi-loader-color: light-dark(#355fcc, #3b82f6);\n /* The grey rectangle a mobile browser flashes under a finger: we draw\n what a press does ourselves (background, ring, chevron), and the\n browser's rectangle is a square painted over rounded corners.\n Declared here alone because the property is inherited: :root covers\n every element, in and out of navi, so nothing has to remember it.\n Set the token to a color to get the flash back, on the whole app or\n on one subtree. */\n -webkit-tap-highlight-color: var(--navi-tap-highlight-color);\n --navi-tap-highlight-color: transparent;\n\n /* A face of their own for the controls, and nothing else on the page.\n Undeclared here on purpose: controls write in inherit, so what a\n container says reaches the button, the field and the picker inside it,\n and a value on :root would resolve here and wall that off again. Set\n it to give the controls their own face, on the whole app or on one\n subtree:\n\n :root {\n --navi-control-font-family: system-ui;\n }\n */\n --navi-control-font-size: ${controlDefaultFontSize};\n --navi-control-border-radius: 2px;\n /* A checkbox is only ~1em wide: applying a control radius meant for\n buttons/inputs (say 8px) would turn it into a circle and make it read\n as a radio. So it follows the control radius but capped at a quarter\n of its own size. Override this token alone to opt out of the cap. */\n --navi-checkbox-border-radius: min(\n var(--navi-control-border-radius),\n 0.25em\n );\n /* The color a control uses to say \"this one is on\": a checked checkbox,\n a checked radio, an enabled switch. Kept apart from --navi-accent-color\n (the brand color for CTA and selection) because a control that is on\n must stay readable as a control, which usually means a brighter, more\n saturated color than a brand accent.\n The light value matches the browser's own accent so a navi control and\n a native one can sit side by side without reading as two blues. */\n --navi-control-accent-color: light-dark(rgb(24, 117, 255), #3b82f6);\n --navi-control-border-width: 1px;\n --navi-control-border-color: light-dark(#767676, #8e8e93);\n --navi-control-padding-x-default: 2px;\n --navi-control-padding-y-default: 1px;\n /* Global padding defaults — override these to change all button paddings. */\n /* Use --button-padding, --button-padding-x, --button-padding-y for per-button overrides. */\n --navi-button-padding-x-default: 6px;\n --navi-button-padding-y-default: 1px;\n /* Read by Button, and by whatever is drawn as one (a picker under\n variant=\"button\"), so the two keep the same surface. */\n --navi-button-background-color: light-dark(#f3f4f6, #2d3748);\n\n /* What a callout says about its message, as a color — and what draws\n as one outside the callout too (the icon a picker in callout mode\n presses, see CalloutStatusIcon). */\n --navi-callout-success-color: #4caf50;\n --navi-callout-info-color: #2196f3;\n --navi-callout-warning-color: #ff9800;\n --navi-callout-error-color: #f44336;\n /* No status: the message is an aside, said without an icon and framed\n like a control rather than like a warning. */\n --navi-callout-neutral-color: var(--navi-control-border-color);\n /* For list item we need slightly more padding to be able to see radio/checkbox outline */\n --navi-list-item-padding-x-default: 4px;\n --navi-list-item-padding-y-default: 1px;\n /* default */\n --navi-picker-padding-x-default: var(--navi-control-padding-x-default);\n --navi-picker-padding-y-default: var(--navi-control-padding-y-default);\n\n --navi-popup-border-radius: 8px;\n --navi-popup-border-color: light-dark(#d0d0d0, #3b3b3b);\n --navi-popup-box-shadow:\n 0 4px 8px rgba(0, 0, 0, 0.08), 0 12px 40px rgba(0, 0, 0, 0.22);\n /* A popup's paper is the same paper as everything else's: derived from\n the surface token so one override themes fields, layouts and popups\n together. Still its own token, so popups alone can be re-papered. */\n --navi-popup-background-color: var(--navi-surface-color);\n /* The ink written on that paper. A popup declares both on itself (see\n dialog.jsx / popover.jsx): it is a new surface, so it does not write\n in whatever the container it was declared in writes in. */\n --navi-popup-color: var(--navi-surface-text-color);\n --navi-backdrop-close-background: rgba(0, 0, 0, 0.08);\n /* backdropVariant=\"discrete\": the popup still catches every outside\n click, it just stops announcing that it did. For an affordance one\n reaches past rather than through — the dim is there to mark the layer,\n not to push the page behind it away. */\n --navi-backdrop-discrete-background: rgba(0, 0, 0, 0.02);\n /* \"capture\" means the rest of the page is fully non-interactive —\n blurred, not just dimmed, so it reads as clearly secondary and\n pulls visual focus onto the popover's own content. */\n --navi-backdrop-capture-background: rgb(255 255 255 / 0.08);\n --navi-backdrop-capture-backdrop-filter: blur(30px) saturate(180%);\n\n /* Link colors. They live here rather than only on .navi_link because a\n var declared on the element itself always beats the same var inherited\n from an ancestor: a page setting --link-color-pressed on :root would\n never reach a link. These :root tokens are the theme-level surface;\n --link-color-* stays the per-link (or per-subtree) override. */\n --navi-link-color: rgb(0, 0, 238);\n /* --navi-link-color-visited is intentionally left undefined: by default a\n visited link is derived from whatever --link-color ended up being on\n the link itself (see link.jsx), which a :root token cannot see. Set it\n here from an app to pin one visited color for every link. */\n --navi-link-color-pressed: red;\n --navi-link-current-indicator-color: rgb(205, 52, 37);\n\n --navi-selection-border-color: #0078d4;\n --navi-selection-background-color: #eaf1fd;\n /* Accent color — used for call-to-action buttons and selected list items.\n Override this single variable to apply a consistent brand color across\n all components that need to stand out. */\n --navi-accent-color: rgb(3, 30, 60);\n /* The surface a control's content sits on — the \"paper\" behind the text.\n Fields use it whenever they need a solid background (their default\n background, a transparent field being edited, …). */\n --navi-surface-color: light-dark(#ffffff, #1c1c1e);\n /* What is written on that paper. The browser's own text color rather\n than a literal, so it follows color-scheme the way the paper does. */\n --navi-surface-text-color: CanvasText;\n /* The line that separates two regions of one surface — a scrolling area's\n header from what scrolls under it, for instance. Not a border: the\n separation belongs to the layout, not to the box that draws it. */\n --navi-separator-color-default: #d1d9e0;\n --navi-color-white: white;\n --navi-color-dark: rgb(55, 60, 69);\n\n --navi-info-color-light: #eaf6fc;\n --navi-info-color: #376cc2;\n --navi-success-color-light: #ecf9ef;\n --navi-success-color: #50c464;\n --navi-warning-color-light: #fdf6e3;\n --navi-warning-color: #f19c05;\n --navi-error-color-light: #fcebed;\n --navi-error-color: #eb364b;\n\n --navi-xxs: 0.125em; /* = 2px at 16px base */\n --navi-xs: 0.25em; /* = 4px at 16px base */\n --navi-s: 0.5em; /* = 8px at 16px base */\n --navi-m: 1em; /* = 16px at 16px base (base font size) */\n --navi-l: 1.5em; /* = 24px at 16px base */\n --navi-xl: 2em; /* = 32px at 16px base */\n --navi-xxl: 3em; /* 48px at 16px base */\n\n --navi-typo-xxs: 0.625rem; /* 10px at 16px base */\n --navi-typo-xs: 0.75rem; /* 12px at 16px base */\n --navi-typo-s: 0.875rem; /* 14px at 16px base */\n --navi-typo-m: 1rem; /* 16px at 16px base (base font size) */\n --navi-typo-l: 1.125rem; /* 18px at 16px base */\n --navi-typo-xl: 1.25rem; /* 20px at 16px base */\n --navi-typo-xxl: 1.5rem; /* 24px at 16px base */\n\n /* The line every text is written on, controls included. A number\n rather than \"normal\", because of the emoji: its box is taller than a\n letter's, so under \"normal\" the one line carrying one stands taller\n than the lines around it, and under a tighter line its top is cut.\n 1.25 is where neither happens — 1 clips the glyph, 1.5 spaces the rows\n out more than reading them asks for — which also makes it the floor\n for an app that displays what people typed.\n The document is written on it; the components that come with a line of\n their own from the browser (Button, Input, Textarea, Select) are handed\n it by name. One number everywhere is what keeps a value on the same\n line whether it is typed in a field or drawn in the page.\n See docs/typography.md. */\n --navi-line-height: 1.25;\n /* The face everything is written in, controls included — they inherit\n it rather than carrying one of their own, so an app names its typeface\n once, wherever it wants it to start: on the document, on a section, on\n a popup opened with font=\"…\". The browser's own control face rather\n than the document's default, so that a page saying nothing gets its\n text and its buttons in one face instead of two.\n Layered like the line above, so the app's own rule wins. */\n font-family: ${controlDefaultFontFamily};\n /* Layered: the document's line is what navi writes on when the app says\n nothing, so the app's own body rule wins. */\n line-height: var(--navi-line-height);\n /* The same line for a control, snapped to the pixel. The browser lays a\n line out at its exact height but paints the glyph on a pixel row: at\n the default control size (13.333px) the line is 16.666px, and the\n two-thirds that do not fit go entirely under the glyph, which then\n sits a pixel above the middle of its field. A whole number of pixels\n has no remainder to put anywhere. Written with em so it resolves on\n the control that uses it, at that control's own size. */\n --navi-control-line-height: round(\n calc(var(--navi-line-height) * 1em),\n 1px\n );\n\n /* Color keywords.\n primary: the ink of the paper, at full strength. An absolute\n rather than currentColor: it is what brings a run back\n to plain text inside a muted one (a value in a secondary\n label). Themed through --navi-surface-text-color.\n secondary: supporting text, captions, less important labels\n emphasis: reinforce meaning, make content stand out more\n discrete: unobtrusive elements that shouldn't compete for attention\n hint: barely-there color, watermarks, ghost placeholders\n The last four mix currentColor toward transparent or black, so they\n follow whatever ink a container writes in: a dark card sets color and\n nothing else. The share of ink in each is the theme's knob, a -mix\n token read wherever the formulas are declared — here and on each\n surface that re-declares them (rules below) — so a ratio set on :root\n reaches the page and its popups alike. Set on a container it reaches\n none of that container's own text, a var() resolving where its custom\n property is declared; a container that wants one keyword otherwise\n for ITS paper (a white at 88% where 80% of white reads too faint on a\n colored resin) pins the keyword itself, and that value stops at the\n next surface. */\n --navi-color-secondary-mix: 80%;\n --navi-color-emphasis-mix: 50%;\n --navi-color-discrete-mix: 60%;\n --navi-color-hint-mix: 25%;\n --navi-color-primary: var(--navi-surface-text-color);\n\n /* What a control shows while it holds nothing: the ::placeholder of an\n input, the empty value slot of a picker. One place for the whole app:\n\n :root {\n --navi-placeholder-color: #8a94a6;\n --navi-placeholder-font-style: italic;\n }\n */\n --navi-placeholder-color: var(--navi-color-discrete);\n --navi-placeholder-font-style: normal;\n }\n\n /* A surface is a new paper: it writes in an ink of its own, and the\n keywords below are computed against that ink rather than the\n container's. A popup writes in --navi-popup-color. */\n .navi_popover,\n .navi_dialog {\n --navi-color-primary: var(--navi-popup-color);\n }\n /* A callout writes in the UA's own ink: callout.js sets color: revert on\n a [popover] element, which the UA styles CanvasText. */\n .navi_callout {\n --navi-color-primary: CanvasText;\n }\n\n /* The keywords derived from the ink, declared on every paper — :root, and\n each surface that is a new one — so what a container pinned for its own\n paper stops at the surface. The -mix ratios (see :root) are read where\n this is declared, and each paper resolves currentColor as its own. On\n the element itself, so a surface beats the container's value whatever\n the layer; an app that wants a surface to keep its container's ink says\n so on the surface, unlayered, and wins in turn. */\n :root,\n .navi_popover,\n .navi_dialog,\n .navi_callout {\n --navi-color-secondary: color-mix(\n in srgb,\n currentColor var(--navi-color-secondary-mix),\n transparent\n );\n --navi-color-emphasis: color-mix(\n in srgb,\n currentColor var(--navi-color-emphasis-mix),\n black\n );\n --navi-color-discrete: color-mix(\n in srgb,\n currentColor var(--navi-color-discrete-mix),\n transparent\n );\n --navi-color-hint: color-mix(\n in srgb,\n currentColor var(--navi-color-hint-mix),\n transparent\n );\n }\n }\n\n /* Hidden appearance */\n input[navi-visually-hidden],\n button[navi-visually-hidden],\n div[navi-visually-hidden] {\n position: absolute;\n top: 0;\n left: 0;\n z-index: -1;\n /* Important to take full size so that scrollIntoView work as expected */\n width: 100%;\n height: 100%;\n margin: 0;\n padding: 0;\n white-space: nowrap;\n border: 0;\n clip-path: inset(50%);\n appearance: none;\n overflow: hidden;\n\n &[navi-debug] {\n position: static;\n width: auto;\n height: auto;\n margin: 0;\n clip-path: none;\n appearance: auto;\n }\n }\n`;\nimport.meta.css = css;\n\neffect(() => {\n document.documentElement.style.setProperty(\n \"--navi-vvw\",\n `${visualViewportWidthSignal.value}px`,\n );\n document.documentElement.style.setProperty(\n \"--navi-vvh\",\n `${visualViewportHeightSignal.value}px`,\n );\n});\n"
20
20
  ]
21
21
  }
@@ -65,8 +65,10 @@ consistency across the app, not from any single call site.
65
65
  routing, actions, layout (`Box`), typography, icons, forms/validation,
66
66
  `Table`, dialogs, popovers.
67
67
  - `docs/resource.md` — REST state: `resource()`, relationships, callback return
68
- contracts, autorerun rules. Companion files: `docs/actions.md`,
69
- `docs/resource_with_params.md`, `docs/resource_dependencies.md`.
68
+ contracts, autorerun rules, and the second question a screen asks over one
69
+ collection (searching it is that collection's own `GET_MANY`). Companion
70
+ files: `docs/actions.md`, `docs/resource_with_params.md`,
71
+ `docs/resource_dependencies.md`.
70
72
  - `docs/list_refresh.md` — what a write sends back to the network and what stays
71
73
  on screen meanwhile: stale data returned by `useAsyncData({ loading: true })`,
72
74
  what updates from a response without any request, `rerunOn` and its defaults,
@@ -80,7 +82,8 @@ consistency across the app, not from any single call site.
80
82
  - `docs/error_handling.md` — the two kinds of error and how navi keeps them
81
83
  apart: where an error is shown depending on where it came from (a control's
82
84
  action shows it on what was clicked, a route action replaces the page, a
83
- refused value is validation and not an error at all), why a run never rejects,
85
+ refused value is validation and not an error at all), what a failing run
86
+ rejects with and who is allowed to swallow that rejection,
84
87
  the `__handled_by__` mark that means "this error is on screen somewhere" (and
85
88
  what `preact/debug` throws over your app without it), what becomes of an error
86
89
  nobody displays, and the two rules any error boundary must follow — mark only
@@ -210,7 +213,12 @@ consistency across the app, not from any single call site.
210
213
  which fields count as already held (a `defaultValue` is a suggestion, a
211
214
  `signal` carrying something is an answer). Read it before a screen that
212
215
  modifies an existing resource (`pristineKey`), and before reaching for
213
- `canSendWhileUnchanged` because a submit "does nothing".
216
+ `canSendWhileUnchanged` because a submit "does nothing". It also holds
217
+ `standalone`, the prop EVERY control takes to stay out of the form, picker or
218
+ group around it — a door that opens something, a picker that commits on its
219
+ own, a list acting on every touch. Read it before writing a control inside a
220
+ group whose value it has no business joining; and note what it does not say
221
+ (`disabled`/`readOnly`/`loading` go on reaching it).
214
222
  - `distributeChildStates` on a group — the way down asked once for ALL the
215
223
  children, when they cannot be placed one at a time (four seats where who sits
216
224
  down decides who moves), and asked again whenever one of them speaks, so the
@@ -281,7 +289,9 @@ consistency across the app, not from any single call site.
281
289
  that picks one of its three modes (does it write to the control it sits in?),
282
290
  the `Box` form that claims interactions and nothing more (so an affordance
283
291
  keeps its own drawing instead of becoming a control), and what the claim does
284
- NOT stop (a plain `onClick` on an ancestor).
292
+ NOT stop (a plain `onClick` on an ancestor). It also draws the line with
293
+ `standalone`: those two answer about the GESTURE, `standalone` about the
294
+ VALUE, and they are three separate props because they come apart.
285
295
  Read it before reading the pointer by hand — who owns a press between nested
286
296
  boxes, and what a touch may do, are decided before the first pixel moves and
287
297
  cannot be got right from outside navi — and before stopping the propagation of
@@ -342,7 +352,10 @@ consistency across the app, not from any single call site.
342
352
  sub-resource (`/games/:id/candidates`) as an `op`/`type` discriminator inside
343
353
  one verb's callback. Read `docs/resource.md` before writing a resource — the
344
354
  callback return contracts differ per relationship method and are not
345
- guessable.
355
+ guessable. Searching a collection is that collection's `GET_MANY` with one
356
+ more param, never a resource or an action of its own: two `GET_MANY` on one
357
+ resource do not compete (each holds its own ids, the store is shared), and
358
+ rows a search returns are then the same objects as everywhere else.
346
359
  - **`Box`** is the layout primitive (Flexbox wrapper: `flex`, `flex="y"` for
347
360
  column, `grid`, `alignX`/`alignY`). Prefer it over raw CSS for layout inside
348
361
  Navi apps.
package/docs/actions.md CHANGED
@@ -34,6 +34,14 @@ Two `bindParams` with equal params give **the same instance** (deep equality),
34
34
  which is what makes state shared between two components asking for the same
35
35
  thing, and what deduplicates their requests.
36
36
 
37
+ It is the params that are compared, never what produced them: four components
38
+ deriving the same question through four `computed` of their own still ask it
39
+ once, and read one state. So a derived params signal can be built wherever it is
40
+ convenient — a `useMemo` per caller included — and nothing has to arrange for
41
+ one instance of it. What this rests on is params being plain data: a `Set`, a
42
+ `Map`, an element, a function or a signal inside them is compared by reference,
43
+ and four callers then really do make four requests.
44
+
37
45
  An action is callable, and calling it is the short way to bind and run in one
38
46
  go:
39
47
 
@@ -49,9 +57,18 @@ flow — where the params are known at that moment and the run is the point:
49
57
  const deleteGame = (game) => GAME.DELETE({ id: game.id });
50
58
  ```
51
59
 
52
- Use `bindParams` when what you need is the **instance**, not the run: to hand it
53
- to a component that will run it and read its state
54
- (`<Button action={GAME.DELETE.bindParams({ id })}>`), or to keep a handle on it.
60
+ A run that fails hands the failure back — it **rejects**, or **throws
61
+ synchronously** when the callback was synchronous — so a gesture behaves like any
62
+ other call that can fail: an `await` on it throws, and an action awaiting it
63
+ fails with it, which is what puts the error on the control that asked for the
64
+ gesture, with nothing to write for it. See [error_handling.md](./error_handling.md#what-a-failing-action-does)
65
+ for the runs that have nobody to reject at, and what they do instead.
66
+
67
+ Use `bindParams` when what you need is the **instance**, not the run: to keep a
68
+ handle on it, or so that a run started in one place is visible from another —
69
+ that shared identity is the whole of what it adds, and a control does not need it
70
+ to draw its own busy and error states (see
71
+ [resource.md](./resource.md#a-function-calling-the-verb-or-the-instance)).
55
72
  Note that calling the action `rerun()`s it — the run happens even if that
56
73
  instance already holds data, which is what you want from a gesture and not what
57
74
  you want from a component asking for data.
@@ -63,6 +80,28 @@ const userAction = getUser.bindParams({ id: userIdSignal });
63
80
  // a new params value reruns it
64
81
  ```
65
82
 
83
+ `{ debounce }` puts a delay between the signal and the instance, for params that
84
+ move faster than a request should — a search box, a wheel, a slider:
85
+
86
+ ```js
87
+ const searchAction = USER.GET_MANY.bindParams(questionSignal, {
88
+ debounce: 300,
89
+ });
90
+ const [found, searching] = useAsyncData(searchAction, {
91
+ run: true,
92
+ loading: true,
93
+ });
94
+ ```
95
+
96
+ There is no effect here and nothing to own: the delay lives in the binding, and
97
+ two call sites passing the same signal and the same delay get the same instance,
98
+ so the four components of one screen reading the same question ask it once. That
99
+ is the shape to reach for whenever the screen owns its params signal.
100
+ `actionRunEffect` is the other one, and what it adds is the run: use it when the request must go out whether or not something
101
+ is drawing it. During the delay the instance is still the previous one, holding
102
+ the previous answer — `loading` is what says a newer one is coming, don't
103
+ compare params by hand to find out.
104
+
66
105
  ## Running: `run`, `rerun`, `prerun`, `reset`
67
106
 
68
107
  | Method | Does |
@@ -266,6 +305,51 @@ one that asks something of a control near it is a `command` (a value proposed is
266
305
  [control_value.md](./control_value.md#a-button-that-proposes-a-value-is---navi-update)).
267
306
  Reaching for a plain `onClick` usually means one of those was missed.
268
307
 
308
+ An `action` and a `command` on the same press are one gesture in two halves, and
309
+ the command is the second one: it waits for the action and runs only if it
310
+ succeeded. "Leave the group, then go back to the list" therefore never leaves
311
+ anyone on a list they are still in — a failure or an abort drops the command and
312
+ the error stays on the control, where the user is looking. `<Picker
313
+ type="confirm">` follows the same rule with its `action` and `command`, and a
314
+ form says it as `data-after-send`.
315
+
316
+ ### Deleting something, then leaving the page it was on
317
+
318
+ The everyday form of that pair, and the press is a `<Picker type="confirm">`
319
+ rather than a `<Button>`: what is destroyed is asked about first, the question
320
+ is a popup the picker already owns, and the work runs back on the trigger.
321
+
322
+ ```jsx
323
+ <Picker
324
+ type="confirm"
325
+ ui="Leave this group"
326
+ message="Leave this group? An invitation will be needed to come back."
327
+ action={() => PLAYER_GROUP.LEAVE({ id: group.id })}
328
+ command="--navi-nav-to:/me/groups"
329
+ />
330
+ ```
331
+
332
+ Yes closes the question and the request goes from the trigger — busy while it
333
+ runs, an error callout on it if it fails — and the list is reached only once the
334
+ group is really gone. A failure leaves the reader on the page of a group they
335
+ are still in, which is the truth, with the reason in front of them.
336
+
337
+ Navigating first and awaiting after — `navTo(...)` then `await leave(...)` — buys
338
+ the frame where this page would render without what it displays, and pays for it
339
+ with a reader standing in a list that still holds the group they think they left,
340
+ told nothing. There is usually no frame to buy anyway: the response and the
341
+ navigation land in the same task.
342
+
343
+ Where to go afterwards is the app's decision, not navi's. Staying on the page to
344
+ say it is done, or letting the route land on its empty state, are as good as
345
+ leaving for the list; what the `command` settles is only that the success is
346
+ what decides.
347
+
348
+ A function calling the verb, not the instance: the trigger is busy and draws the
349
+ error either way, and `bindParams` earns its place only when something else on
350
+ screen must see this very run — see
351
+ [resource.md](./resource.md#a-function-calling-the-verb-or-the-instance).
352
+
269
353
  On a link — a `Link`, a `<Button href>` or `<Button route>` — the three fire on
270
354
  the press, before the navigation, and the navigation waits for none of them: a
271
355
  `command="--navi-close"` closes the sheet the link leaves, an `action` that
@@ -80,9 +80,10 @@ there.
80
80
  ```
81
81
 
82
82
  A nameless **leaf** is a different story: it is a control whose value has
83
- nowhere to go, and it is warned about. When that is deliberate — a control that
84
- only opens something — say so with `allowNameless` (see
85
- [form_changed.md](./form_changed.md#a-control-that-is-not-a-field)).
83
+ nowhere to go, and it is warned about. A control that is not answering this
84
+ group's question at all — one that only opens something, one that commits on
85
+ its own — says so with `standalone` and leaves the group entirely (see
86
+ [form_changed.md](./form_changed.md#a-control-that-answers-for-itself)).
86
87
 
87
88
  ## A picker whose value is an object
88
89
 
@@ -117,20 +118,26 @@ Two things to get right:
117
118
  url.
118
119
  - **A control that helps FIND the answer is not the answer.** A search box above
119
120
  a long list, a "select all" beside it: they are tools, and a tool says so with
120
- `allowNameless`. The picker then walks past it and talks to the list, which is
121
+ `standalone`. The picker then walks past it and talks to the list, which is
121
122
  what a popup made of one choice and the means to reach it needs. Never put it
122
123
  on the control that IS the value — the picker would have nobody left to fill,
123
- and the popup would open blank on a value it holds (navi says so in dev).
124
+ and the popup would open blank on a value it holds.
124
125
 
125
126
  ```jsx
126
127
  <Picker name="place_ids" type="array">
127
- <Input allowNameless placeholder="chercher" navi-list="places" />
128
+ <Input standalone placeholder="chercher" navi-list="places" />
128
129
  <List id="places" selectable multiple>
129
130
  …
130
131
  </List>
131
132
  </Picker>
132
133
  ```
133
134
 
135
+ A popup can also hold **no** answer for the picker: several lists that are the
136
+ same one choice, each acting on every touch, nothing read back on close. Mark
137
+ them all `standalone` and the picker talks to none of them — the shape to reach
138
+ for when one `ControlGroup` around them would be wrong, because its value would
139
+ be one key per list where the answer is a single value.
140
+
134
141
  ## A group holds what it was given
135
142
 
136
143
  A group's value looks like it is made of its children, and mostly it is — but
@@ -169,15 +169,47 @@ server runs too, or both — is `docs/field_validation.md`.
169
169
 
170
170
  ## What a failing action does
171
171
 
172
- It writes the error into `errorSignal`, moves to `FAILED`, and stops.
172
+ It writes the error into `errorSignal`, moves to `FAILED`, and hands the failure
173
+ back to whoever asked for the run.
173
174
 
174
- **A run never rejects.** `run()`, `rerun()`, `prerun()` settle with the error as
175
- their value. Nothing about a failed action arrives as a rejected promise, so
176
- there is no floating rejection to catch and no navigation that has to await one
177
- to stay quiet. Code that wants the error asks for it — `useAsyncData`,
178
- `errorSignal`, or an `onError` passed to the run.
175
+ **A failing run fails the way it ran.** `run()`, `rerun()`, `prerun()` — and
176
+ `ACTION(params)`, which is `bindParams(params).rerun()` — reject with the error
177
+ when the callback was asynchronous, and **throw it synchronously** when it was
178
+ not: code that knows its action is synchronous writes a plain `try/catch` around
179
+ the call and has to find the failure there, not in a rejected promise it never
180
+ awaited. Either way it fails like any other call that can fail, and an action
181
+ awaiting another one fails with it:
179
182
 
180
- Why it does no more than that: **at the instant an action fails, nothing can
183
+ ```js
184
+ // GROUP.LEAVE fails → this rejects → the button's action fails → the button
185
+ // shows the error, and a command following it does not run
186
+ <Button action={() => GROUP.LEAVE({ id: group.id })} />
187
+ ```
188
+
189
+ The error is in `errorSignal` all the same: a screen reads it there whether or
190
+ not anything was waiting on the promise. `useAsyncData`, `errorSignal` and an
191
+ `onError` passed to the run are still how a render asks for it.
192
+
193
+ Code that lets such a rejection float gets the runtime's own unhandled-error
194
+ report — window `error`, the jsenv overlay in dev — and that is the right answer
195
+ for it: nothing had to guess whether someone was waiting.
196
+
197
+ **Some runs have nobody to reject at**, and they say so where they start: one
198
+ started from a signal effect because its params changed, one whose failure the
199
+ control that started it already draws, a routing whose result every caller drops.
200
+ They pass through [`runUnwatched()`](../src/action/run_unwatched.js), which takes
201
+ the rejection without hiding the error — it stays in `errorSignal`. That is the
202
+ whole list of places navi swallows one, and taking it is what obliges that place
203
+ to ask the next question: did anything display this error? (below).
204
+
205
+ Two things must never become a rejection, and do not. **A navigation**: the
206
+ routing's promise is what the browser calls the navigation itself, and rejecting
207
+ it would abort a navigation whose page is busy explaining what failed. **The end
208
+ of an update**: several actions run for one update, so their outcomes are
209
+ gathered with `allSettled` — one failure is an outcome to record, not something
210
+ that takes the other five down.
211
+
212
+ Why the failure does no more than that: **at the instant an action fails, nothing can
181
213
  know whether a screen will display it.** A route action runs before its page
182
214
  renders — often before that page exists — so any decision made there about "will
183
215
  someone show this?" is a guess, and it is wrong for the most common case in the
@@ -157,21 +157,37 @@ holds it back and says what it is waiting for.
157
157
  </Button>
158
158
  ```
159
159
 
160
- ## A control that is not a field
160
+ ## A control that answers for itself
161
161
 
162
162
  A control inside a form is expected to carry a value under its name, and a
163
163
  nameless one is warned about — its state would silently stay out of what is
164
- sent. A control that only opens something (a picker whose popup draws a shape, a
165
- row whose value is carried by a hidden input beside it) says so with
166
- `allowNameless`:
164
+ sent. But some controls inside a form are not answering the form's question at
165
+ all: a picker that commits what it holds on its own, a door that only opens
166
+ something, a list acting on every touch. They say so with `standalone`:
167
167
 
168
168
  ```jsx
169
- <Picker allowNameless ui={…}>
169
+ <Picker standalone ui={…} action={commit} />
170
170
  ```
171
171
 
172
- It is then neither collected nor complained about — and inside a picker's popup
173
- it is not the control the picker talks to either, which is what lets a search
174
- box sit above the list that IS the answer (see
172
+ `standalone` is not a filter, it is the absence of a relationship: the control
173
+ does not register with the group around it. Nothing goes up — its value is not
174
+ collected, and the form stays unchanged whatever it does — and nothing comes
175
+ down either: the form's reset does not reach it, nor does its validation
176
+ cascade.
177
+
178
+ What it does **not** say is "ignore everything around me". `disabled`,
179
+ `readOnly` and `loading` travel on their own contexts and go on reaching it,
180
+ and that is deliberate: a door that carries no value of its own can still
181
+ write into the form through what its popup does, and a read-only form must
182
+ still shut it. An affordance that genuinely writes nowhere says that
183
+ separately, with `whenSelfInteractionsBlocked="ignore"` — as it says whose
184
+ press it is with `selfInteractions`. Three questions, three props (see
185
+ [interactions.md](./interactions.md#the-third-question-whose-value-is-it)).
186
+
187
+ Every control takes the prop, groups included — `<Form standalone>` is the
188
+ same sentence, and so is a `<List standalone>` inside a picker's popup, which
189
+ is what lets a search box or a second list sit beside the one that IS the
190
+ answer (see
175
191
  [control_object.md](./control_object.md#a-picker-whose-value-is-an-object)).
176
192
 
177
193
  ## See also
@@ -558,11 +558,11 @@ the edge one grabs to carry it.
558
558
 
559
559
  So the claim names its interactions, and what it does not name stays the zone's:
560
560
 
561
- | written | takes | leaves |
562
- | ------------------------------ | ------------------------- | ----------------------------------- |
563
- | `selfInteractions="click"` | the press | the grab — the card is still carried by it |
564
- | `selfInteractions="click drag"`| both | — |
565
- | `selfInteractions="*"` | every gesture, now and later | — |
561
+ | written | takes | leaves |
562
+ | ------------------------------- | ---------------------------- | ------------------------------------------ |
563
+ | `selfInteractions="click"` | the press | the grab — the card is still carried by it |
564
+ | `selfInteractions="click drag"` | both | — |
565
+ | `selfInteractions="*"` | every gesture, now and later | — |
566
566
 
567
567
  `"*"` is there for the case where it is true, not as a shorthand: it is the one
568
568
  value that will silently swallow a gesture navi has not shipped yet.
@@ -578,11 +578,11 @@ becomes of the affordance where the zone around it is disabled or read-only.
578
578
  The claimed interactions are its subject, and only them: the ones left to the
579
579
  zone were never this element's to block.
580
580
 
581
- | what it does | written | on a blocked zone |
582
- | ------------------------------------------------------------ | -------------------------------------------- | ----------------------------------------- |
583
- | writes to it (a cross that removes, a stepper) | nothing — `"hide"` is the default | it goes |
584
- | writes to it, and its presence says there is something there | `whenSelfInteractionsBlocked="refuse"` | it stays and refuses with a callout |
585
- | never touches it (a diskette saving into MY address book) | `whenSelfInteractionsBlocked="ignore"` | nothing changes: still lit, still pressed |
581
+ | what it does | written | on a blocked zone |
582
+ | ------------------------------------------------------------ | -------------------------------------- | ----------------------------------------- |
583
+ | writes to it (a cross that removes, a stepper) | nothing — `"hide"` is the default | it goes |
584
+ | writes to it, and its presence says there is something there | `whenSelfInteractionsBlocked="refuse"` | it stays and refuses with a callout |
585
+ | never touches it (a diskette saving into MY address book) | `whenSelfInteractionsBlocked="ignore"` | nothing changes: still lit, still pressed |
586
586
 
587
587
  A greyed cross that still removes is worse than no cross — hence the default.
588
588
  `"ignore"` is the other extreme and the caller owns it: the zone's read-only is
@@ -593,6 +593,28 @@ when that is really the case.
593
593
  Busy is not on the list because busy does not block: it is the read-only a
594
594
  running action sets on its way that does.
595
595
 
596
+ #### The third question: whose value is it?
597
+
598
+ `selfInteractions` and `whenSelfInteractionsBlocked` are about the **gesture** —
599
+ who a pointer event belongs to, and what a block on the box around it does to
600
+ that gesture. Whose **value** an element carries is a separate question, and
601
+ `standalone` answers it: the control does not register with the form, picker or
602
+ group around it, so what it holds never joins that value (see
603
+ [form_changed.md](./form_changed.md#a-control-that-answers-for-itself)).
604
+
605
+ The three come apart, which is why they are three props:
606
+
607
+ | the element | says |
608
+ | ------------------------------------------------------- | --------------------------------------------------------------------------- |
609
+ | a chip's cross | `selfInteractions` — it is a button, it never carried a value |
610
+ | a door opening a sheet that writes into the form | `standalone` — no value of its own, but a read-only form must still shut it |
611
+ | a diskette filing a name into the reader's address book | all three — own press, block is not about it, own value |
612
+
613
+ Reading `standalone` as "ignore everything around me" is the trap: `disabled`,
614
+ `readOnly` and `loading` travel on their own contexts and go on reaching it,
615
+ because "what do I hold" and "may anything be changed here" are not the same
616
+ question.
617
+
596
618
  #### On something you draw yourself
597
619
 
598
620
  `selfInteractions` is a `Box` prop too, so an affordance does not have to become
@@ -298,7 +298,7 @@ about components, not about the DOM.
298
298
  ### Composing a value, or doing work
299
299
 
300
300
  A picker mirrors **one** control in its popup — the first one that is not a
301
- button, a link or a control saying it is not the answer (`allowNameless`). That
301
+ button, a link or a control that answers for itself (`standalone`). That
302
302
  mirror is what makes `<Picker><List selectable/></Picker>` work with nothing
303
303
  wired: the picker's value IS the list's, both ways, and the picker's `action`
304
304
  runs on it when the popup closes.