From 3fd562c57d2afb9d9d8eb071ee15c8cf25a3cee6 Mon Sep 17 00:00:00 2001 From: joe <133191653+excom-dev@users.noreply.github.com> Date: Wed, 23 Sep 2026 06:55:46 -0500 Subject: [PATCH 1/6] fix view dist --- .../heft-rig/main_2026-09-23-dist-files.json | 11 +++++++++++ .../views/install-section/install-section.html | 2 +- .../heft-rig/scripts/build-package-metas.mjs | 11 +++++++++-- .../support/tests/build-package-metas.test.ts | 16 ++++++++++++++++ 4 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 common/changes/@excom/heft-rig/main_2026-09-23-dist-files.json diff --git a/common/changes/@excom/heft-rig/main_2026-09-23-dist-files.json b/common/changes/@excom/heft-rig/main_2026-09-23-dist-files.json new file mode 100644 index 0000000..f7eced2 --- /dev/null +++ b/common/changes/@excom/heft-rig/main_2026-09-23-dist-files.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@excom/heft-rig", + "comment": "Package metas read `exports` from the built `dist/exports.generated.json` when package.json has none (it is only applied at publish time), so the docs-site \"View Dist Files\" dialog is no longer empty", + "type": "patch" + } + ], + "packageName": "@excom/heft-rig", + "email": "133191653+excom-dev@users.noreply.github.com" +} diff --git a/packages/docs-site/public/views/install-section/install-section.html b/packages/docs-site/public/views/install-section/install-section.html index 97a079b..a906310 100644 --- a/packages/docs-site/public/views/install-section/install-section.html +++ b/packages/docs-site/public/views/install-section/install-section.html @@ -60,7 +60,7 @@

All exported dist files:

- +
@@ -427,7 +464,7 @@
Packages
- + @@ -445,11 +482,11 @@
Packages
- + - + Packages data-app="returns-app" data-files="html quark css"> - + @@ -479,7 +516,8 @@
Packages
- + \ No newline at end of file diff --git a/packages/docs-site/public/apple-touch-icon.png b/packages/docs-site/public/apple-touch-icon.png index 8568e1240d13211df3669f5bc0032cd6762830f4..0048808f009d17d31e2232e8fb9c29972b4c2bf2 100644 GIT binary patch literal 12137 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 literal 4343 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 z
- + diff --git a/packages/docs-site/public/views/package/package.css b/packages/docs-site/public/views/package/package.css index efa131e..6d6d3a6 100644 --- a/packages/docs-site/public/views/package/package.css +++ b/packages/docs-site/public/views/package/package.css @@ -265,17 +265,6 @@ include-content[data-demo] { background: var(--v-card-sectioning-background-color); font-size: 0.9rem; color: var(--v-muted-color); - - /* Each piece's logo; the list order in INTRODUCTION.md is fixed. - Multi-colour marks as a background, like the site header's . */ - &::before { - content: ""; - display: block; - width: 2rem; - height: 2rem; - margin-bottom: 0.6rem; - background: var(--stack-logo) center / contain no-repeat; - } &:nth-child(1) { --stack-logo: url("/img/nucleus.svg"); } @@ -291,10 +280,21 @@ include-content[data-demo] { strong:first-of-type { display: block; - margin-bottom: 0.35em; + margin-bottom: 0.5em; font-size: 1.05rem; font-weight: 600; color: var(--v-color); + display: flex; + /* Each piece's logo; the list order in INTRODUCTION.md is fixed. + Multi-colour marks as a background, like the site header's . */ + &::before { + content: ""; + display: inline-block; + width: 1.5rem; + height: 1.5rem; + background: var(--stack-logo) center / contain no-repeat; + margin-right: 0.6rem; + } } } } @@ -361,21 +361,39 @@ include-content[data-demo] { } #md-next-steps + ul, -#md-start-here + ol { +#md-start-here + ul { list-style: none; padding: 0; counter-reset: start-here; > li { counter-increment: start-here; - display: flex; - align-items: center; - gap: 0.85rem; margin: 0 0 0.55rem; padding: 0.85rem 1rem; border: 1px solid var(--v-muted-border-color); border-radius: var(--v-border-radius); background: var(--v-card-background-color); + transition: background 0.2s ease-out; + &:not(:has(spa-a ~ spa-a)) { + display: flex; + align-items: center; + gap: 0.85rem; + &:has(spa-a[data-completed], spa-a[is-active], spa-a[was-active]) { + background: var(--v-contrast-inverse); + &::before { + background: var(--v-icon-valid); + color: transparent; + } + } + spa-a { + text-decoration: none; + --v-color: revert; + } + } + &:has(spa-a ~ spa-a)::before { + float: left; + margin-right: 0.85rem; + } &::before { content: counter(start-here); @@ -390,6 +408,9 @@ include-content[data-demo] { font-size: 0.85rem; font-weight: 600; line-height: 1; + transition: + background 0.2s ease-out, + color 0.2s ease-out; } } } diff --git a/packages/docs-site/public/views/package/package.quark b/packages/docs-site/public/views/package/package.quark index c698262..bf33f86 100644 --- a/packages/docs-site/public/views/package/package.quark +++ b/packages/docs-site/public/views/package/package.quark @@ -54,3 +54,9 @@ include-content[data-language] { [bind-copy-button] { content: template("#copy-source-button"); } +#md-next-steps + ul, +#md-start-here + ul { + spa-a:not([data-completed], [is-active]) { + data-completed: didCompleteLink(element); + } +} \ No newline at end of file diff --git a/packages/docs-site/shell.css b/packages/docs-site/shell.css index 67caf61..4683979 100644 --- a/packages/docs-site/shell.css +++ b/packages/docs-site/shell.css @@ -39,10 +39,9 @@ body { } /* The static pointer to the plain-text docs (index.html, first child of ``). - Styled for the CSS-loads-but-JS-fails path; `detect-browser` upgrades the moment - Nucleus Kit is imported, so `:defined` is the "JavaScript ran" signal and the - notice comes down without a line of script. */ + Styled for the CSS-loads-but-JS-fails path */ .agent-notice { + display: none; padding: var(--v-spacing, 1rem); font-size: 0.75rem; line-height: 1.5; @@ -51,8 +50,10 @@ body { margin: 0; } } -body:has(detect-browser:defined) .agent-notice { - display: none; +@media (scripting: none) { + .agent-notice { + display: block !important; + } } /* wait until loaded so no FOUC */ @@ -273,26 +274,29 @@ main display: contents; } -.package-links hgroup { - margin-top: calc(var(--v-spacing)); - margin-bottom: calc(var(--v-spacing) * 0.25); - text-align: center; - position: relative; - &::before { - content: ""; - position: absolute; - top: 50%; - left: 0; - width: 100%; - height: 1px; - background-color: var(--v-muted-border-color); - z-index: -1; - } - h5 { - width: fit-content; - background-color: var(--v-background-color); - padding: 0 8px; - margin: 0 auto; +.package-links { + padding-bottom: var(--v-spacing); + hgroup { + margin-top: calc(var(--v-spacing)); + margin-bottom: calc(var(--v-spacing) * 0.25); + text-align: center; + position: relative; + &::before { + content: ""; + position: absolute; + top: 50%; + left: 0; + width: 100%; + height: 1px; + background-color: var(--v-muted-border-color); + z-index: -1; + } + h5 { + width: fit-content; + background-color: var(--v-background-color); + padding: 0 8px; + margin: 0 auto; + } } } @@ -612,6 +616,9 @@ body { } } .site-nav-footer { + /* to push package links up a bit */ + margin-top: var(--site-header-height) !important; + padding-inline: calc(var(--v-spacing) * 0.5) !important; /* fade in delay because nav items load and it's jerky */ transition: opacity 0.4s ease-out 1s; @starting-style { @@ -691,20 +698,32 @@ body { > .desktop-only { display: none; } - /* logos row */ - > p.mobile-only { + /* socials row */ + > ul.mobile-only { display: flex; align-items: center; justify-content: center; - gap: var(--v-spacing); - margin: 0 0 calc(var(--v-spacing) * 0.75); + gap: calc(var(--v-spacing) * 1.1); + list-style: none; + margin: 0; + padding: 0; + border: 0; font-size: inherit; - spa-a { - display: inline-flex; + > li { + margin: 0; + padding: 0; + } + spa-a:has(img[src*="excom"]) { + display: flex; + flex-direction: column; align-items: center; - gap: calc(var(--v-spacing) * 0.4); + gap: calc(var(--v-spacing) * 0.2); color: inherit; text-decoration: none; + padding-right: 36px; + small { + margin: 0; + } } img { width: auto; @@ -714,22 +733,7 @@ body { height: 2rem; } img[src*="excom"] { - height: 0.85rem; - } - } - /* socials row */ - > ul.mobile-only { - display: flex; - align-items: center; - justify-content: center; - gap: calc(var(--v-spacing) * 1.1); - list-style: none; - margin: 0 0 calc(var(--v-spacing) * 0.75); - padding: 0; - border: 0; - > li { - margin: 0; - padding: 0; + height: 0.9rem; } } [data-social] { diff --git a/packages/docs-site/shell.ts b/packages/docs-site/shell.ts index 1f4478c..3b84097 100644 --- a/packages/docs-site/shell.ts +++ b/packages/docs-site/shell.ts @@ -576,3 +576,8 @@ export const formatTsType = (_type: string, tag: string): string => { }; export const focusInput = (e) => e.target.querySelector("input")?.focus(); + +export const didCompleteLink = (element: Element): boolean => { + const past = element.closest("spa-manager")?.router?.previousStates; + return !!past?.find(state => state.url === element.getAttribute("route-href")); +}; \ No newline at end of file diff --git a/packages/docs-site/support/docs/BEST_PRACTICES.md b/packages/docs-site/support/docs/BEST_PRACTICES.md index 645ceab..e8a47d0 100644 --- a/packages/docs-site/support/docs/BEST_PRACTICES.md +++ b/packages/docs-site/support/docs/BEST_PRACTICES.md @@ -22,6 +22,7 @@ Short rules with rationale. They exist because the document *is* the state — k - **`preserve` while loading.** `content: $todo.title or preserve;` keeps the last good value instead of flashing empty. - **Never render what you match.** A rule that renders children (via `content:`) that match its own selector re-triggers itself until the loop guard cuts it (50 nested paints, logged as `Loop guard: …`). - **Don't let rules gate on each other's writes.** `[data-a="1"] { data-b: "1"; }` next to `[data-b="1"] { data-a: "2"; }` is a cycle: Quark warns when the sheet builds (*rules gate on attributes they write for each other*) and the loop guard cuts it at run time if it never settles. Give the transition one attribute with a value, derive the second fact from the first in one direction only, or gate one side on a guard attribute. A rule cannot loop on the attribute it writes itself — that write never re-runs the same declaration. +- **Gate a one-shot on the fact it writes.** Rules re-run, and every run restarts a [`@delay`](/nucleus/packages/quark/delay), so an unconditional `@delay 3000 { is-open: ""; }` reopens a dismissed banner. Write the fact with the effect and gate on it: `&:not([data-did-open]) { @delay 3000 { is-open: ""; data-did-open: ""; } }`. - **Do not use variables to smuggle side effects.** An unused `$x: doThing();` is an element and an event in disguise. - **Put the fact in the document before branching on it.** `if()` is fine, but many rules testing the same condition (`if($user.role == "admin": …)`) mean a fact is missing from the State. Write it once as an attribute (`data-is-admin: $user.role == "admin";`), then select on it: `[data-is-admin] button { … }`. The condition becomes declarative, addressable by CSS as well as Quark, visible in devtools, and evaluated in one place. - **Prefer interpolation over concatenation.** `"Items: #{$n}"` and `"/api/users/#{$id}"`, not `"Items: " + $n` or `"/api/users/" + $id`. `+` stays for arithmetic. diff --git a/packages/docs-site/support/docs/BUILDING_VIEWS.md b/packages/docs-site/support/docs/BUILDING_VIEWS.md index 593042b..f348b71 100644 --- a/packages/docs-site/support/docs/BUILDING_VIEWS.md +++ b/packages/docs-site/support/docs/BUILDING_VIEWS.md @@ -149,3 +149,14 @@ Everything above works on a server-rendered or CMS page. Start with one element ## Load order Put `` first inside its host so it registers before sibling elements connect. Prefer reacting to state attributes (`is-success`, `is-active`) over one-shot events for anything that can happen during boot. See [Orchestrating](/nucleus/docs/orchestrating). + +## Next steps + +- [Quick Start - A working page, in five minutes.](/nucleus/docs/quick_start) +- [Core Concepts - The mental model, in one sitting.](/nucleus/docs/core_concepts) +- [Using Elements - The Nucleus Kit catalog and how elements behave.](/nucleus/docs/using_elements) +- [Orchestrating - Get familiar with Quark.](/nucleus/docs/orchestrating) +- [Styling - Valence.css themes, tokens, and state-driven CSS.](/nucleus/docs/styling) +- [Building Views - Structure a real app: routes, views, lazy loading.](/nucleus/docs/building_views) +- Other Guides - [Business Logic](/nucleus/docs/business_logic), [Creating Elements](/nucleus/docs/creating_elements), [Best Practices](/nucleus/docs/best_practices), [Troubleshooting](/nucleus/docs/troubleshooting), [Debugging with Agents](/nucleus/docs/debugging_with_agents) +- [Diving Deeper - The architecture behind it all, for the curious and the skeptical.](/nucleus/docs/diving_deeper) diff --git a/packages/docs-site/support/docs/CORE_CONCEPTS.md b/packages/docs-site/support/docs/CORE_CONCEPTS.md index 6831bb2..d6dd61c 100644 --- a/packages/docs-site/support/docs/CORE_CONCEPTS.md +++ b/packages/docs-site/support/docs/CORE_CONCEPTS.md @@ -79,4 +79,13 @@ Functions should be pure: take values, return values. Side effects are allowed w | Style something | **CSS / Valence.css**, keyed to state attributes | | Package a chunk of UI | A **view** (HTML + CSS + Quark) | -Continue with [Using Elements](/nucleus/docs/using_elements) and [Orchestrating](/nucleus/docs/orchestrating). For the full architectural treatment, see [Adapter, State, Orchestrator](/nucleus/docs/adapter_state_orchestrator). +## Next steps + +- [Quick Start - A working page, in five minutes.](/nucleus/docs/quick_start) +- [Core Concepts - The mental model, in one sitting.](/nucleus/docs/core_concepts) +- [Using Elements - The Nucleus Kit catalog and how elements behave.](/nucleus/docs/using_elements) +- [Orchestrating - Get familiar with Quark.](/nucleus/docs/orchestrating) +- [Styling - Valence.css themes, tokens, and state-driven CSS.](/nucleus/docs/styling) +- [Building Views - Structure a real app: routes, views, lazy loading.](/nucleus/docs/building_views) +- Other Guides - [Business Logic](/nucleus/docs/business_logic), [Creating Elements](/nucleus/docs/creating_elements), [Best Practices](/nucleus/docs/best_practices), [Troubleshooting](/nucleus/docs/troubleshooting), [Debugging with Agents](/nucleus/docs/debugging_with_agents) +- [Diving Deeper - The architecture behind it all, for the curious and the skeptical.](/nucleus/docs/diving_deeper) diff --git a/packages/docs-site/support/docs/INTRODUCTION.md b/packages/docs-site/support/docs/INTRODUCTION.md index 0f39a39..c078d5c 100644 --- a/packages/docs-site/support/docs/INTRODUCTION.md +++ b/packages/docs-site/support/docs/INTRODUCTION.md @@ -70,7 +70,11 @@ The Nucleus Stack is MIT licensed and will remain free and open source. This is ## Start here -1. [Quick Start](/nucleus/docs/quick_start) A working page, in five minutes. -2. [Core Concepts](/nucleus/docs/core_concepts) The mental model, in one sitting. -3. [Using Elements](/nucleus/docs/using_elements) and [Orchestrating](/nucleus/docs/orchestrating) The two skills you'll use daily. -4. [Diving Deeper](/nucleus/docs/diving_deeper) The architecture behind it all, for the curious and the skeptical. +- [Quick Start - A working page, in five minutes.](/nucleus/docs/quick_start) +- [Core Concepts - The mental model, in one sitting.](/nucleus/docs/core_concepts) +- [Using Elements - The Nucleus Kit catalog and how elements behave.](/nucleus/docs/using_elements) +- [Orchestrating - Get familiar with Quark.](/nucleus/docs/orchestrating) +- [Styling - Valence.css themes, tokens, and state-driven CSS.](/nucleus/docs/styling) +- [Building Views - Structure a real app: routes, views, lazy loading.](/nucleus/docs/building_views) +- Other Guides - [Business Logic](/nucleus/docs/business_logic), [Creating Elements](/nucleus/docs/creating_elements), [Best Practices](/nucleus/docs/best_practices), [Troubleshooting](/nucleus/docs/troubleshooting), [Debugging with Agents](/nucleus/docs/debugging_with_agents) +- [Diving Deeper - The architecture behind it all, for the curious and the skeptical.](/nucleus/docs/diving_deeper) diff --git a/packages/docs-site/support/docs/ORCHESTRATING.md b/packages/docs-site/support/docs/ORCHESTRATING.md index 028a351..a036a26 100644 --- a/packages/docs-site/support/docs/ORCHESTRATING.md +++ b/packages/docs-site/support/docs/ORCHESTRATING.md @@ -347,3 +347,14 @@ Load a sheet before the elements it listens to begin their lifecycles: put `` at the top of the fragment. Scope view CSS to the view's root element and keep shared layout in one site-wide stylesheet. See [Building Views](/nucleus/docs/building_views). + +## Next steps + +- [Quick Start - A working page, in five minutes.](/nucleus/docs/quick_start) +- [Core Concepts - The mental model, in one sitting.](/nucleus/docs/core_concepts) +- [Using Elements - The Nucleus Kit catalog and how elements behave.](/nucleus/docs/using_elements) +- [Orchestrating - Get familiar with Quark.](/nucleus/docs/orchestrating) +- [Styling - Valence.css themes, tokens, and state-driven CSS.](/nucleus/docs/styling) +- [Building Views - Structure a real app: routes, views, lazy loading.](/nucleus/docs/building_views) +- Other Guides - [Business Logic](/nucleus/docs/business_logic), [Creating Elements](/nucleus/docs/creating_elements), [Best Practices](/nucleus/docs/best_practices), [Troubleshooting](/nucleus/docs/troubleshooting), [Debugging with Agents](/nucleus/docs/debugging_with_agents) +- [Diving Deeper - The architecture behind it all, for the curious and the skeptical.](/nucleus/docs/diving_deeper) diff --git a/packages/docs-site/support/docs/USING_ELEMENTS.md b/packages/docs-site/support/docs/USING_ELEMENTS.md index 36631c5..8c551dd 100644 --- a/packages/docs-site/support/docs/USING_ELEMENTS.md +++ b/packages/docs-site/support/docs/USING_ELEMENTS.md @@ -103,3 +103,14 @@ The Nucleus Kit catalog exists to give the *same* contract to protocols the plat - **Select on state, not on classes.** Setting attributes is recommended over toggling / mutating classes and ids, since the latter has a heavier impact on Quark's performance. Keep classes for static styling. - **Set attributes, not properties, before upgrade.** If script runs before an element's definition has loaded, `setAttribute()` is honored on upgrade; a property assignment is not. + +## Next steps + +- [Quick Start - A working page, in five minutes.](/nucleus/docs/quick_start) +- [Core Concepts - The mental model, in one sitting.](/nucleus/docs/core_concepts) +- [Using Elements - The Nucleus Kit catalog and how elements behave.](/nucleus/docs/using_elements) +- [Orchestrating - Get familiar with Quark.](/nucleus/docs/orchestrating) +- [Styling - Valence.css themes, tokens, and state-driven CSS.](/nucleus/docs/styling) +- [Building Views - Structure a real app: routes, views, lazy loading.](/nucleus/docs/building_views) +- Other Guides - [Business Logic](/nucleus/docs/business_logic), [Creating Elements](/nucleus/docs/creating_elements), [Best Practices](/nucleus/docs/best_practices), [Troubleshooting](/nucleus/docs/troubleshooting), [Debugging with Agents](/nucleus/docs/debugging_with_agents) +- [Diving Deeper - The architecture behind it all, for the curious and the skeptical.](/nucleus/docs/diving_deeper) diff --git a/packages/docs-site/support/tests/__snapshots__/live-app.view.test.ts.snap b/packages/docs-site/support/tests/__snapshots__/live-app.view.test.ts.snap index bd18103..4e67def 100644 --- a/packages/docs-site/support/tests/__snapshots__/live-app.view.test.ts.snap +++ b/packages/docs-site/support/tests/__snapshots__/live-app.view.test.ts.snap @@ -7,13 +7,13 @@ exports[`live-app view > renders one editor per file, saves edits, reloads the p "getVar": 1, "importNode": 2, "listenerRuns": 0, - "matches": 176, - "parentElement": 171, + "matches": 216, + "parentElement": 176, "quarkRuns": 2, - "queryScopeCost": 318, + "queryScopeCost": 258, "querySelectorAll": 51, "removeAttribute": 6, - "ruleRuns": 53, + "ruleRuns": 51, "schedulePaint": 4, "setAttribute": 9, "setVar": 0, diff --git a/packages/docs-site/support/tests/package.view.test.ts b/packages/docs-site/support/tests/package.view.test.ts index ae75c04..d4cc297 100644 --- a/packages/docs-site/support/tests/package.view.test.ts +++ b/packages/docs-site/support/tests/package.view.test.ts @@ -18,7 +18,9 @@ import { flush, readViewFile, } from "@excom/quark/support/tests/view-helpers"; +import { renderMarkdown } from "@excom/heft-rig/scripts/render-markdown.mjs"; import { + didCompleteLink, docMetaUrl, docNeighbors, docTitle, @@ -41,9 +43,20 @@ const shellStub = { getDocHtml, docNeighbors, docTitle, + didCompleteLink, upgradeTemplateCode: () => {}, }; +/** One `## ` section of a site guide, rendered like the site meta. */ +const guideSection = (file: string, heading: string) => { + const md = readViewFile(import.meta.url, `../docs/${file}`); + const section = md + .split(/\n(?=## )/) + .find((s) => s.startsWith(`## ${heading}\n`)); + if (!section) throw new Error(`${file} has no "## ${heading}"`); + return renderMarkdown(section); +}; + const neutronMeta: PackageMeta = { shortName: "neutron", package: { @@ -87,6 +100,11 @@ const siteMeta = { docs: { introduction: '

Introduction

', core_concepts: '

Core Concepts

', + // the guide link lists `package.quark` marks as visited + guide_links: + '

Guide links

' + + guideSection("INTRODUCTION.md", "Start here") + + guideSection("QUICK_START.md", "Next steps"), }, docSections: [ { @@ -134,9 +152,20 @@ const mountPage = async ( { expectFetch = true, routeDocName, - }: { expectFetch?: boolean; routeDocName?: string } = {}, + previousUrls, + }: { + expectFetch?: boolean; + routeDocName?: string; + /** Mount under a real `spa-manager` whose `router` is stubbed to report these past urls. */ + previousUrls?: string[]; + } = {}, ) => { - const host = document.createElement("div"); + const host = document.createElement(previousUrls ? "spa-manager" : "div"); + if (previousUrls) { + (host as HTMLElement & { router: unknown }).router = { + previousStates: previousUrls.map((url) => ({ url })), + }; + } host.innerHTML = `spa-route { $route: prop("provision"); $route-doc-name: attr("data-doc-name"); }`; const route = host.querySelector( "spa-route", @@ -289,4 +318,29 @@ describe("package view", () => { expect(page.hasAttribute("is-success")).toBe(false); expect(page.querySelector(".md-content")?.innerHTML).toBe(""); }); + + it("marks guide links the router has a past visit to", async () => { + const quickStart = "/nucleus/docs/quick_start"; + const styling = "/nucleus/docs/styling"; + const { page } = await mountPage( + { name: "guide_links" }, + { previousUrls: ["/nucleus", quickStart, styling] }, + ); + await vi.waitFor(() => + expect( + page.querySelectorAll("#md-start-here + ul spa-a[data-completed]"), + ).toHaveLength(2), + ); + + for (const list of ["#md-start-here + ul", "#md-next-steps + ul"]) { + const completed = [ + ...page.querySelectorAll(`${list} spa-a[data-completed]`), + ].map((a) => a.getAttribute("route-href")); + expect(completed).toEqual([quickStart, styling]); + // every other guide link, including the "Other Guides" row, stays open + expect(page.querySelectorAll(`${list} spa-a`).length).toBeGreaterThan(8); + } + // links outside the two lists are never marked + expect(page.querySelectorAll("spa-a[data-completed]")).toHaveLength(4); + }); }); diff --git a/packages/docs-site/support/tests/release-notice.view.test.ts b/packages/docs-site/support/tests/release-notice.view.test.ts index c9eef93..a20b3a4 100644 --- a/packages/docs-site/support/tests/release-notice.view.test.ts +++ b/packages/docs-site/support/tests/release-notice.view.test.ts @@ -1,5 +1,7 @@ import "@excom/content-drawer"; +import "@excom/quark-sheet"; import "@excom/super-form"; +import { invokeCommand } from "@excom/neutron"; import { afterEach, describe, @@ -12,6 +14,7 @@ import { } from "@excom/heft-rig/profiles/default/config/test-utils"; import { flush, + mountView as mountWithSheet, readViewFile, } from "@excom/quark/support/tests/view-helpers"; @@ -22,12 +25,31 @@ const html = readViewFile( const stripAssets = (s: string) => s.replace(//g, ""); +/** The `#release-notice { … }` rule of the shell sheet in `index.html`. */ +const shellRule = () => { + const shell = readViewFile(import.meta.url, "../../index.html"); + const start = shell.indexOf("#release-notice {"); + let depth = 0; + for (let i = shell.indexOf("{", start); i < shell.length; i++) { + depth += shell[i] === "{" ? 1 : shell[i] === "}" ? -1 : 0; + if (!depth) return shell.slice(start, i + 1); + } + throw new Error("index.html has no #release-notice rule"); +}; + /** The banner is static: no sheet, no provision — CSS drives the lifecycle. */ const mountView = () => fixture(stripAssets(html)); +/** Fake-timer `flush()`: advance `ms`, then drain what the due timers queued. */ +const tick = async (ms = 0) => { + await vi.advanceTimersByTimeAsync(ms); + for (let i = 0; i < 8; i++) await vi.advanceTimersByTimeAsync(0); +}; + describe("release notice view", () => { afterEach(() => { document.body.innerHTML = ""; + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -115,4 +137,54 @@ describe("release notice view", () => { }); expect(superForm.hasAttribute("is-success")).toBe(true); }); + + it("keeps its clicks from bubbling past the banner (shell sheet)", async () => { + const { root } = await mountWithSheet( + `${shellRule()}${html}`, + ); + const outside = vi.fn(); + document.addEventListener("click", outside); + document.addEventListener("mouseup", outside); + try { + const inner = root.querySelector("#release-notice blockquote p")!; + for (const type of ["mouseup", "click"]) { + inner.dispatchEvent(new MouseEvent(type, { bubbles: true })); + } + expect(outside).not.toHaveBeenCalled(); + + // the rest of the page still bubbles + root.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(outside).toHaveBeenCalledTimes(1); + } finally { + document.removeEventListener("click", outside); + document.removeEventListener("mouseup", outside); + } + }); + + // a popover toggle appends a proxy button under , which re-runs the + // whole shell sheet; an ungated `@delay` restarted and reopened the banner + it("opens once after 3s; a sheet re-run never reopens it (shell sheet)", async () => { + vi.useFakeTimers(); + const mounted = mountWithSheet( + `${shellRule()}${html}`, + ); + await tick(50); + const { root } = await mounted; + const notice = root.querySelector("#release-notice")!; + + await tick(2850); + expect(notice.hasAttribute("is-open")).toBe(false); + await tick(150); + expect(notice.hasAttribute("is-open")).toBe(true); + expect(notice.hasAttribute("data-did-open")).toBe(true); + + invokeCommand(notice, "--close", notice.querySelector("button.close")); + await tick(); + expect(notice.hasAttribute("is-open")).toBe(false); + + root.append(document.createElement("button")); + await tick(3500); + expect(notice.hasAttribute("is-open")).toBe(false); + expect(notice.hasAttribute("data-did-open")).toBe(true); + }); }); diff --git a/packages/docs-site/support/tests/shell.test.ts b/packages/docs-site/support/tests/shell.test.ts index ae5c895..28bc3d9 100644 --- a/packages/docs-site/support/tests/shell.test.ts +++ b/packages/docs-site/support/tests/shell.test.ts @@ -6,15 +6,19 @@ import { it, vi, } from "@excom/heft-rig/profiles/default/config/test-utils"; -import { readFileSync } from "node:fs"; +import { readdirSync, readFileSync } from "node:fs"; import { KitRoute, KitRouter } from "@excom/kit-router"; import { readViewFile } from "@excom/quark/support/tests/view-helpers"; import { createRequire } from "node:module"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderMarkdown } from "@excom/heft-rig/scripts/render-markdown.mjs"; import { addLengths, buildAppFileLink, buildGitHubLink, copySource, + didCompleteLink, displayName, docMetaUrl, docNeighbors, @@ -450,6 +454,42 @@ describe("editor helpers", () => { }); }); +describe("didCompleteLink", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + /** A `spa-a` under a `spa-manager` stand-in whose router reports `past`. */ + const linkIn = (href: string, past?: Array<{ url: string }>) => { + const manager = document.createElement("spa-manager") as HTMLElement & { + router?: { previousStates: Array<{ url: string }> }; + }; + if (past) manager.router = { previousStates: past }; + manager.innerHTML = `
`; + document.body.append(manager); + return manager.querySelector("spa-a")!; + }; + + it("is true when the link's route is a state before the active one", () => { + const past = [{ url: "/nucleus" }, { url: "/nucleus/docs/quick_start" }]; + expect(didCompleteLink(linkIn("/nucleus/docs/quick_start", past))).toBe( + true + ); + }); + + it("is false for an unvisited route or one only reachable forward", () => { + const past = [{ url: "/nucleus" }]; + expect(didCompleteLink(linkIn("/nucleus/docs/styling", past))).toBe(false); + expect(didCompleteLink(linkIn("/nucleus/docs/styling", []))).toBe(false); + }); + + it("is false outside a spa-manager or before it has a router", () => { + document.body.innerHTML = ``; + expect(didCompleteLink(document.querySelector("spa-a")!)).toBe(false); + expect(didCompleteLink(linkIn("/nucleus"))).toBe(false); + }); +}); + describe("search", () => { it("filterSearchResults returns nothing for short / empty queries or unknown corpus versions", () => { expect(filterSearchResults(corpus, "")).toEqual([]); @@ -702,6 +742,47 @@ describe("site base trailing slash", () => { }); }); +describe("site guides", () => { + const dir = resolve(dirname(fileURLToPath(import.meta.url)), "../docs"); + const guides = readdirSync(dir) + .filter((f) => f.endsWith(".md") && f !== "INTERNAL.md") + .map((file) => ({ file, md: readFileSync(resolve(dir, file), "utf8") })); + const names = new Set( + guides.map(({ file }) => file.slice(0, -3).toLowerCase()) + ); + + it("link only to guides that exist", () => { + const broken = guides.flatMap(({ file, md }) => + [...md.matchAll(/\]\(\/nucleus\/docs\/(\w+)/g)] + .filter(([, name]) => !names.has(name)) + .map(([, name]) => `${file} → ${name}`) + ); + expect(broken).toEqual([]); + }); + + /* `package.quark` / `package.css` style these two lists as `+ ul` cards. */ + it("render Start here / Next steps as a bullet list of spa links", () => { + const lists = guides.flatMap(({ file, md }) => { + const doc = new DOMParser().parseFromString( + renderMarkdown(md), + "text/html" + ); + return [...doc.querySelectorAll("#md-start-here, #md-next-steps")].map( + (h) => ({ file, list: h.nextElementSibling }) + ); + }); + expect(lists.map(({ file }) => file)).toContain("INTRODUCTION.md"); + expect(lists.length).toBeGreaterThan(1); + for (const { file, list } of lists) { + expect(list?.tagName, file).toBe("UL"); + expect( + list!.querySelectorAll("spa-a[route-href]").length, + file + ).toBeGreaterThan(0); + } + }); +}); + describe("shiki grammar imports", () => { // `@use "/shell"` is a native `import()` in the browser, so the highlighter's // grammar imports must be something Vite dev can rewrite. es-module-lexer diff --git a/packages/gesture-handler/gesture-handler.ts b/packages/gesture-handler/gesture-handler.ts index 19f89c7..8248032 100644 --- a/packages/gesture-handler/gesture-handler.ts +++ b/packages/gesture-handler/gesture-handler.ts @@ -1,10 +1,5 @@ import { selectAll, selectOne } from "@excom/kit-utils"; -import { - ConstructorType, - Neutron, - TEvent, - TokenList, -} from "@excom/neutron"; +import { ConstructorType, Neutron, TEvent, TokenList } from "@excom/neutron"; export type GestureType = | "pan" @@ -136,7 +131,8 @@ interface Handoff { pointerType: string; x: number; y: number; - container: Element; + /** Where the pointer went down. The first move walks up from here */ + target: Element; } type Handle = ReturnType; @@ -198,6 +194,8 @@ const VELOCITY_WINDOW_MS = 100; const HANDOFF_MIN_PX = 3; /** Slack (px) still counted as at scroll limit */ const LIMIT_EPSILON = 1; +/** Computed `overflow-*` of an element the user can scroll */ +const SCROLLS = /auto|scroll|overlay/; /** Extra travel time (ms) when projecting snap target */ const PROJECTION_MS = 120; const WINDOW_EVENTS = ["pointermove", "pointerup", "pointercancel"]; @@ -359,10 +357,10 @@ export const GestureHandler = Neutron({ * selector, comma list matches several) — e.g. a sheet closed by pulling * its own content down. * - * Pointerdown inside one scrolls natively; gesture takes over only when - * first move runs along `progress-axis`, container is at that scroll - * limit, and `progress-offset` still has room that way. Additive to - * `from-ref` / `from-edge`. + * Pointerdown inside one scrolls natively; the gesture takes over only when + * the first move runs along `progress-axis`, every scroller between pointer + * and this element is at its limit that way, and `progress-offset` has room. + * Additive to `from-ref` / `from-edge`. * @values */ handoffRef: String, @@ -812,36 +810,32 @@ function originAllowed(element: El, e: PointerEvent): boolean { }; if (fromEdge.some((edge) => insets[edge] <= edgePx)) return true; } - if (handoffContainer(element, e.target)) return false; + if (inHandoff(element, e.target)) return false; return !fromRef && !fromEdge?.length; } -/** Innermost `handoff-ref` container this node sits in, if any (matches - * in document order; last containing match is nearest — a scrolling editor - * inside a non-scrolling sheet must be judged by its own scroll, not the sheet's). */ -function handoffContainer(element: El, node: EventTarget | null) { - const { handoffRef } = element; - if (!handoffRef || !node) return null; - const containers = (selectAll(handoffRef, { scope: element }) || []).filter( - (container) => container.contains(node as Node) - ); - return containers[containers.length - 1] || null; -} +/** `handoff-ref` matches */ +const handoffContainers = (element: El): Element[] => + element.handoffRef + ? selectAll(element.handoffRef, { scope: element }) || [] + : []; + +/** Does this node sit in a `handoff-ref` container? */ +const inHandoff = (element: El, node: EventTarget | null) => + !!node && handoffContainers(element).some((c) => c.contains(node as Node)); function pendingHandoff(element: El, e: PointerEvent): Handoff | null { const { isDisabled, pointerTypes, _session } = element; if (_session || isDisabled || e.button > 0) return null; if (!pointerTypes.includes(e.pointerType)) return null; - const container = handoffContainer(element, e.target); - return ( - container && { - pointerId: e.pointerId, - pointerType: e.pointerType, - x: e.clientX, - y: e.clientY, - container, - } - ); + if (!inHandoff(element, e.target)) return null; + return { + pointerId: e.pointerId, + pointerType: e.pointerType, + x: e.clientX, + y: e.clientY, + target: e.target as Element, + }; } /** Direction `--gesture-progress` grows, as `createSession` reads it. */ @@ -872,11 +866,28 @@ function atScrollLimit(container: Element, direction: GestureDirection) { } } +/* Checks if any intermediate nodes between `target` and `element` are scrollable. */ +function canScroll(element: El, target: Element, direction: GestureDirection) { + const containers = handoffContainers(element); + const overflow = AXES[direction][0] ? "overflowX" : "overflowY"; + for ( + let n: Element | null = target; + n && n !== element; + n = n.parentElement + ) { + if (atScrollLimit(n, direction)) continue; + if (containers.includes(n) || SCROLLS.test(getComputedStyle(n)[overflow])) { + return true; + } + } + return false; +} + /** - * Does this first move hand over? Must run along `progress-axis`, find - * container at its limit that way, and have progress left to travel. + * First move hands over: along `progress-axis`, no scroller below the pointer + * with room that way, progress left to travel. */ -function handsOver(element: El, container: Element, dx: number, dy: number) { +function handsOver(element: El, target: Element, dx: number, dy: number) { const forward = progressDirection(element); const [ax, ay] = AXES[forward]; const along = dx * ax + dy * ay; @@ -889,7 +900,7 @@ function handsOver(element: El, container: Element, dx: number, dy: number) { along > 0 ? progressOffset < progressMax : progressOffset > progressMin; return ( headroom && - atScrollLimit(container, along > 0 ? forward : OPPOSITE[forward]) + !canScroll(element, target, along > 0 ? forward : OPPOSITE[forward]) ); } @@ -909,7 +920,7 @@ function handoffEffects(element: El, event: Event): unknown[] | undefined { const dx = pointer.clientX - h.x; const dy = pointer.clientY - h.y; if (Math.hypot(dx, dy) < HANDOFF_MIN_PX) return; // too early to tell - if (!handsOver(element, h.container, dx, dy)) { + if (!handsOver(element, h.target, dx, dy)) { return [{ _handoff: null }]; // container scrolls; leave it } if (touch) event.preventDefault(); // no native scroll for this sequence diff --git a/packages/gesture-handler/support/docs/README.md b/packages/gesture-handler/support/docs/README.md index 8a6b5d2..583cbbd 100644 --- a/packages/gesture-handler/support/docs/README.md +++ b/packages/gesture-handler/support/docs/README.md @@ -55,7 +55,7 @@ Give a drag handle `touch-action: none` when using `from-ref`, so the browser do ### Scroll handoff -`handoff-ref` names the scroll container(s) inside the surface whose *overscroll* starts a gesture (a `:scope`-relative selector; a comma list matches several). A pointer that goes down in one of them scrolls natively as usual. Only when its **first** move runs along `progress-axis`, the container is at its scroll limit that way, and `progress-offset` still has room to travel in that direction does the element cancel the native scroll for the rest of the touch and take the drag over — the same gesture, the same `--gesture-*` values and the same `-start` / `-end` / `-snap` events as a drag from a handle: +`handoff-ref` names the scroll container(s) inside the surface whose *overscroll* starts a gesture (a `:scope`-relative selector; a comma list matches several). A pointer that goes down in one of them scrolls natively as usual. Only when its **first** move runs along `progress-axis`, nothing between the pointer and the element can still scroll that way (the named containers and any scroll container nested in or around them, so a scrolled editor inside a sheet scrolls back first), and `progress-offset` still has room to travel in that direction does the element cancel the native scroll for the rest of the touch and take the drag over — the same gesture, the same `--gesture-*` values and the same `-start` / `-end` / `-snap` events as a drag from a handle: ```html { up({ x: 100, y: 300 }); }); + it("handoff-ref waits for every scroller between the pointer and the element", async () => { + // docs-site editor sheet: a named sheet and a named sideways-only editor, + // with the file body scrolling vertically between them + const { el } = mount( + `gesture-types="pan-y swipe" progress-axis="up" progress-offset="1" + range-px="200" handoff-ref="[data-sheet], [data-editor]"`, + `
+

content

+
` + ); + await frame(); + const body = el.querySelector("[data-body]")!; + const content = el.querySelector("p")!; + const pull = (y: number) => { + down(el, { x: 100, y: 300 }, content); + return touchMove(content, 100, y).defaultPrevented; + }; + + // scrolled body: pulling down scrolls it back up (innermost match is at 0) + body.scrollTop = 40; + expect(pull(306)).toBe(false); + expect(el.hasAttribute("is-active")).toBe(false); + + // an unscrollable node (overflow: hidden) with an offset is not a scroller + body.scrollTop = 0; + content.scrollTop = 40; + expect(pull(306)).toBe(true); + expect(el.hasAttribute("is-active")).toBe(true); + up({ x: 100, y: 306 }); + }); + it("handoff-ref ignores a move with no progress left that way, or across the axis", async () => { const { el, content } = mountHandoff(); await frame(); diff --git a/packages/kit-router/index.ts b/packages/kit-router/index.ts index 648c176..95f0fe7 100644 --- a/packages/kit-router/index.ts +++ b/packages/kit-router/index.ts @@ -283,6 +283,14 @@ export class KitRouter { return null; } + public get previousStates() { + return this.states.slice(0, this.getStateIndex(history.state?.id)); + } + + public get nextStates() { + return this.states.slice(this.getStateIndex(history.state?.id) + 1); + } + private beforePushState() { const index = this.getStateIndex(history.state?.id); const currentState = this.getActiveState(index); diff --git a/packages/kit-router/support/tests/kit-router-history.test.ts b/packages/kit-router/support/tests/kit-router-history.test.ts index f665951..609b4f0 100644 --- a/packages/kit-router/support/tests/kit-router-history.test.ts +++ b/packages/kit-router/support/tests/kit-router-history.test.ts @@ -180,6 +180,41 @@ describe("KitRouter history", () => { expect(router.canGoForward()).toBe(false); }); + it("previousStates / nextStates split the states around the active entry", () => { + router = new KitRouter(); + router.on(new KitRoute(/.*/, () => {})); + const urls = (states: Array<{ url: string }>) => states.map((s) => s.url); + expect(router.previousStates).toEqual([]); + expect(router.nextStates).toEqual([]); + + router.pushState({ url: "/a" }); + router.pushState({ url: "/b" }); + expect(urls(router.previousStates)).toEqual(["/", "/a"]); + expect(router.nextStates).toEqual([]); + + const states = statesOf(router); + popstate(states[1].id); + expect(urls(router.previousStates)).toEqual(["/"]); + expect(urls(router.nextStates)).toEqual(["/b"]); + + popstate(states[0].id); + expect(router.previousStates).toEqual([]); + expect(urls(router.nextStates)).toEqual(["/a", "/b"]); + + // copies: mutating them does not touch the router + router.nextStates.pop(); + expect(statesOf(router)).toHaveLength(3); + }); + + it("previousStates / nextStates treat an unknown history id as the first state", () => { + router = new KitRouter(); + router.on(new KitRoute(/.*/, () => {})); + router.pushState({ url: "/a" }); + history.replaceState({ id: "unknown" }, ""); + expect(router.previousStates).toEqual([]); + expect(router.nextStates.map((s) => s.url)).toEqual(["/a"]); + }); + it("keeps state metadata (title, scroll, transition types)", () => { router = new KitRouter(); const handler = vi.fn(); diff --git a/packages/nucleus-kit/nucleus-kit.progressive.ts b/packages/nucleus-kit/nucleus-kit.progressive.ts index 9924fc2..094ae95 100644 --- a/packages/nucleus-kit/nucleus-kit.progressive.ts +++ b/packages/nucleus-kit/nucleus-kit.progressive.ts @@ -5,7 +5,8 @@ * under `dist/progressive/` and load once. * * Vs the all-in `index.umd.min.js`: one extra round-trip before upgrade - * (style the pre-upgrade state yourself), and ESM only. + * (style the pre-upgrade state yourself), and ESM only. Opt into idle loading + * (prefetch the rest after page load) with ``. */ type Loader = () => Promise; @@ -94,7 +95,7 @@ const scan = (node: Node) => { * on `document` at import time; call it yourself for a shadow root. */ export const observeElements = ( - root: Document | Element | ShadowRoot + root: Document | Element | ShadowRoot, ): (() => void) => { scan(root); const observer = new MutationObserver((records) => { @@ -104,4 +105,56 @@ export const observeElements = ( return () => observer.disconnect(); }; +const loaded = new Promise((resolve) => + document.readyState == "complete" + ? resolve(0) + : window.addEventListener("load", resolve, { once: true }), +); + +/** Next idle period (≤ 3 s away); a 100 ms timer without `requestIdleCallback` (Safari). */ +const idle = () => + new Promise((resolve) => + typeof requestIdleCallback == "function" + ? requestIdleCallback(resolve, { timeout: 3000 }) + : setTimeout(resolve, 100), + ); + +/** Data-saver mode (Chromium; `prefers-reduced-data` ships nowhere yet). */ +const saveData = () => + (navigator as { connection?: { saveData?: boolean } }).connection?.saveData; + +/** + * After `load`, prefetch the packages behind `tags` (default all) one per idle + * period, skipping loaded ones, unknown tags (warned) and Save-Data mode. + * The entry calls this itself on ` +``` + +An empty value loads everything; a space-separated list loads only the packages behind those tags (unknown tags log a warning). `data-idle` is read only from the `` + + ``, + ); + const { loadedTags } = await freshEntry(); + await vi.advanceTimersByTimeAsync(10_000); + expect(loadedTags()).toEqual(["content-drawer"]); + }); + + it("waits for the page load event", async () => { + vi.spyOn(document, "readyState", "get").mockReturnValue("loading"); + document.body.setAttribute("nucleus-kit-idle", "content-drawer"); + const { loadedTags } = await freshEntry(); + await vi.advanceTimersByTimeAsync(10_000); + expect(loadedTags()).toEqual([]); + window.dispatchEvent(new Event("load")); + await vi.advanceTimersByTimeAsync(100); + expect(loadedTags()).toEqual(["content-drawer"]); + }); + + it("does nothing in data-saver mode", async () => { + Object.defineProperty(navigator, "connection", { value: { saveData: true }, configurable: true }); + document.body.setAttribute("nucleus-kit-idle", ""); + const { loadedTags } = await freshEntry(); + await vi.advanceTimersByTimeAsync(10_000); + expect(loadedTags()).toEqual([]); + }); + + it("uses requestIdleCallback: next package only after the previous one evaluated", async () => { + stubIdle(); + const { entry, spy } = await freshEntry(); + let evaluate!: (value: unknown) => void; + spy("content-drawer").mockImplementation(() => new Promise((resolve) => (evaluate = resolve))); + const done = entry.idleLoadElements(["content-drawer", "data-table"]); + await runIdle(); + expect(requestIdleCallback).toHaveBeenCalledWith(expect.any(Function), { timeout: 3000 }); + expect(spy("content-drawer")).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(10_000); + expect(idleQueue).toHaveLength(0); + expect(spy("data-table")).not.toHaveBeenCalled(); + evaluate(undefined); + await runIdle(); + await done; + expect(spy("data-table")).toHaveBeenCalledOnce(); + }); + + it("skips packages the observer already loaded", async () => { + stubIdle(); + const { entry, spy } = await freshEntry(); + document.body.innerHTML = ``; + await vi.waitFor(() => expect(spy("content-drawer")).toHaveBeenCalledOnce()); + const done = entry.idleLoadElements(["content-drawer", "data-table"]); + await runIdle(); + await done; + expect(requestIdleCallback).toHaveBeenCalledOnce(); + expect(spy("content-drawer")).toHaveBeenCalledOnce(); + expect(spy("data-table")).toHaveBeenCalledOnce(); + }); + + it("does not reload a package when its tag appears after the sweep", async () => { + const { entry, spy } = await freshEntry(); + await Promise.all([entry.idleLoadElements(["super-form"]), vi.advanceTimersByTimeAsync(100)]); + document.body.innerHTML = ``; + await vi.advanceTimersByTimeAsync(100); + expect(spy("super-form")).toHaveBeenCalledOnce(); + }); + + it("keeps going after a failed import", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const { entry, spy } = await freshEntry(); + spy("content-drawer").mockRejectedValueOnce(new Error("offline")); + await Promise.all([ + entry.idleLoadElements(["content-drawer", "data-table"]), + vi.advanceTimersByTimeAsync(200), + ]); + expect(spy("data-table")).toHaveBeenCalledOnce(); + expect(console.error).toHaveBeenCalledWith("[nucleus-kit] failed to load ", expect.any(Error)); + }); +}); diff --git a/packages/quark/src/observer.ts b/packages/quark/src/observer.ts index 71a33fc..eac58bc 100644 --- a/packages/quark/src/observer.ts +++ b/packages/quark/src/observer.ts @@ -1,7 +1,12 @@ import { CHILD_REMOVED } from "./constants"; import type { QuarkListenerConfig } from "./rule"; import { deref, QuarkLogger } from "./utils"; -type CB = (arg: { element: HTMLElement; attribute: string }) => any; +type CB = (arg: { + element: HTMLElement; + attribute: string; + /** `content` records: the inserted elements. */ + added?: Element[]; +}) => any; /** Attach the sheet's root event listeners (prop changes, binding changes). */ export const listen = (listenerConfig: QuarkListenerConfig) => { @@ -10,13 +15,8 @@ export const listen = (listenerConfig: QuarkListenerConfig) => { }); }; -/** True when `nodes` holds at least one element. */ -const hasElement = (nodes: NodeList) => { - for (const node of nodes) { - if (node.nodeType === Node.ELEMENT_NODE) return true; - } - return false; -}; +const isElement = (node: Node): node is Element => + node.nodeType === Node.ELEMENT_NODE; const classTokens = (value: string | null) => new Set(value ? value.split(/\s+/) : []); @@ -44,12 +44,10 @@ const classNamesFlipped = ( * rules reference; with `classNames`, a `class` record counts only when * one of those tokens was added or removed (styling churn on other * classes is dropped here). childList records queue on the *parent*: - * insertions as `content` (matching rules run; `content` rules below - * the insert re-run); when `childRemovals` is set (a rule depends on - * children or sibling position: `:has()`, `:nth-child()`, `a + b`), - * element-only removals as `CHILD_REMOVED`. Text-only records have - * nothing to match. Who changed the nodes (Quark, an element, app JS) - * does not matter; no render-event contract. + * insertions as `content` with the inserted elements, element-only + * removals as `CHILD_REMOVED` when `childRemovals` is set (a rule depends + * on children / sibling position). Text-only records have nothing to + * match; who changed the nodes does not matter, no render-event contract. */ export const observe = ( host: HTMLElement | WeakRef, @@ -77,9 +75,10 @@ export const observe = ( } cb({ element: target as HTMLElement, attribute: attributeName }); } else if (type === "childList") { - if (hasElement(record.addedNodes)) { - cb({ element: target as HTMLElement, attribute: "content" }); - } else if (childRemovals && hasElement(record.removedNodes)) { + const added = [...record.addedNodes].filter(isElement); + if (added.length) { + cb({ element: target as HTMLElement, attribute: "content", added }); + } else if (childRemovals && [...record.removedNodes].some(isElement)) { cb({ element: target as HTMLElement, attribute: CHILD_REMOVED }); } } diff --git a/packages/quark/src/quark.ts b/packages/quark/src/quark.ts index f0ae898..2e1635b 100644 --- a/packages/quark/src/quark.ts +++ b/packages/quark/src/quark.ts @@ -26,6 +26,7 @@ import { Rule, transitionSpec } from "./rule"; import { acquireScopeId, releaseScopeId } from "./scope-id"; import { addBusyCheck, whenSettled } from "./settle"; import type { + InsertedNodes, MutationMap, QuarkListenerConfig, QuarkOptions, @@ -171,6 +172,8 @@ export class Quark { /** `prop()` subscriptions this sheet holds, released on unregister. */ private propSubscriptions: Map void>> = new Map(); ELEMENTS_TO_MATCH: MutationMap = new Map(); + /** `content` parent → nodes inserted this batch (`null`: queued without them, whole subtree) */ + ADDED_NODES: Map | null> = new Map(); allAttrs: string[] = []; /** * `$name` → properties in this sheet that reference it. Bindings @@ -265,11 +268,21 @@ export class Quark { mapToRun.delete(element); } }); + // nodes gone again (a proxy button in and out in one tick) add nothing + const inserted: InsertedNodes = new Map( + [...this.ADDED_NODES] + .filter((entry): entry is [HTMLElement, Set] => !!entry[1]) + .map(([parent, nodes]) => [ + parent, + [...nodes].filter((node) => parent.contains(node)), + ]) + ); + this.ADDED_NODES.clear(); // the causal depth the queued changes inherited (see queueRunRules) const depth = this.pendingDepth; this.pendingDepth = 0; if (mapToRun.size) { - LoopGuard.run(depth, () => this.run(mapToRun)); + LoopGuard.run(depth, () => this.run(mapToRun, {}, inserted)); } } /** @@ -282,9 +295,12 @@ export class Quark { queueRunRules = ({ element, attribute, + added, }: { element: HTMLElement; attribute: string; + /** `content`: the inserted elements (observer); omitted → whole subtree. */ + added?: Element[]; }) => { this.pendingDepth = Math.max( this.pendingDepth, @@ -296,6 +312,12 @@ export class Quark { } else { this.ELEMENTS_TO_MATCH.set(element, new Set([attribute])); } + if (attribute === "content") { + const known = this.ADDED_NODES.get(element); + if (!added || known === null) this.ADDED_NODES.set(element, null); + else if (known) added.forEach((node) => known.add(node)); + else this.ADDED_NODES.set(element, new Set(added)); + } if (!this.isRunningRules) { this.isRunningRules = true; // todo: clean up - double setTimeout(0) ensures that a new quark run is triggered after the current paint is complete. @@ -438,7 +460,12 @@ export class Quark { }); } - run(mutationMap: MutationMap, options: QuarkOptions = {}) { + /** `inserted` scopes `content` entries to the nodes the observer saw arrive. */ + run( + mutationMap: MutationMap, + options: QuarkOptions = {}, + inserted?: InsertedNodes + ) { const host = this.host.deref(); if (!host) return QuarkLogger.error("Quark: Host not found"); const runId = options?.runId || generateID(); @@ -474,6 +501,7 @@ export class Quark { ) { rule.run(mutationMap, { host, + inserted, options: { runId, ...this.options, @@ -628,6 +656,7 @@ export class Quark { // a delayed block must not fire into a sheet that let go this.delayTimers.forEach((timer) => clearTimeout(timer)); this.delayTimers.clear(); + this.ADDED_NODES.clear(); unobserve(this.observer, this.listenerConfig); this.observer = null; this.propSubscriptions.forEach((byName) => diff --git a/packages/quark/src/rule.ts b/packages/quark/src/rule.ts index 40d933e..9368337 100644 --- a/packages/quark/src/rule.ts +++ b/packages/quark/src/rule.ts @@ -34,12 +34,24 @@ import { type SelectorAnalysis, splitScopePrefix, } from "./selector-utils"; -import type { MutationMap, QuarkOptions, TransitionSpec } from "./types"; +import type { + InsertedNodes, + MutationMap, + QuarkOptions, + TransitionSpec, +} from "./types"; import { isInfoLogging, QuarkLogger } from "./utils"; import { selectAll, tc } from "@excom/kit-utils"; let quarkRuleIdCounter = 0; +const byDocumentOrder = (a: Node, b: Node) => + a === b + ? 0 + : a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING + ? -1 + : 1; + /** * Elements not contained by another in the list, in the list's own * order (fan-out order is observable: it decides which match's @@ -52,13 +64,7 @@ export const outermostElements = (elements: HTMLElement[]): HTMLElement[] => { if (elements.length < 2) return elements; const sorted = elements .slice() - .sort((a, b) => - a === b - ? 0 - : a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING - ? -1 - : 1 - ); + .sort(byDocumentOrder); const kept = new Set(); let last: HTMLElement | undefined; for (const el of sorted) { @@ -70,6 +76,53 @@ export const outermostElements = (elements: HTMLElement[]): HTMLElement[] => { return elements.filter((el) => kept.has(el)); }; +/** A query after a batch: its root, and for an insertion scan what it keeps. */ +export type Scan = { root: HTMLElement; accept: Map | null }; + +/** + * Queries a rule makes after a batch: `full` roots scan their whole subtree, + * insertion parents scan once and keep matches inside their inserted nodes + * (`accept`: node -> counts itself). Nested parents merge into the outermost; + * a full root under a scanned parent rides along, descendants only. + */ +export const planScans = ( + full: HTMLElement[], + inserted: Map +): Scan[] => { + const scans = outermostElements( + [...inserted.keys()].filter((p) => !full.some((r) => r.contains(p))) + ) + .map((root) => { + const accept = new Map(); + inserted.forEach((nodes, p) => { + if (root.contains(p)) nodes.forEach((n) => accept.set(n, true)); + }); + return { root, accept }; + }) + .filter(({ accept }) => accept.size); + const rest = full.filter((r) => { + const scan = scans.find(({ root }) => root.contains(r)); + if (scan && !scan.accept.has(r)) scan.accept.set(r, false); + return !scan; + }); + return [...rest.map((root) => ({ root, accept: null })), ...scans]; +}; + +/** + * Up to this many outermost inserted nodes are queried directly; more share + * one parent-wide query filtered by `isAccepted` (a 1000-row `iterate()` stays one query). + */ +export const DIRECT_SCAN_MAX = 8; + +/** `el` is an accepted node or below one (see `planScans`), up to `root`. */ +const isAccepted = (el: Element, accept: Map, root: Node) => { + for (let n: Node | null = el; n && n !== root; n = n.parentNode) { + const self = accept.get(n as Element); + if (self || (self === false && n !== el)) return true; + } + return false; +}; + /** * Mutation kinds run structural first: `RUN_ALL`, then `NEW_SELF`, then * `content` / `CHILD_REMOVED`, then attr names and `$bindings`. @@ -446,15 +499,18 @@ export class Rule { { host, options, + inserted, }: { host: HTMLElement; options: QuarkOptions; + inserted?: InsertedNodes; } ) { const propertiesToRun = this.filterPropertiesToRun(options); if (propertiesToRun.length > 0) { this._run(mutationMap, { host, + inserted, options: { ...options, propertiesToRun }, }); } @@ -464,9 +520,11 @@ export class Rule { { host, options, + inserted, }: { host: HTMLElement; options: QuarkOptions; + inserted?: InsertedNodes; } ) { const elementsToMutate = new Set(); @@ -507,6 +565,8 @@ export class Rule { const fanOut = (root: HTMLElement | null) => { if (root) roots.add(root); }; + /** Insertion parents → the nodes inserted below them (see `planScans`). */ + const insertions = new Map(); const rootFor = (el: HTMLElement, where: FanOutRoot) => where === "parent" ? (el.parentElement ?? el) : el; /** @@ -569,8 +629,11 @@ export class Rule { } else if (attr === "content" || attr === CHILD_REMOVED) { if (attr === CHILD_REMOVED && !this.reactsToRemovals) return; if (!host.contains(element)) return; - // matching children were added / removed below `element` - fanOut(element); + // inserted nodes only, unless position deps (`:nth-child()`, `+` / `~`), + // removals or a caller without nodes need the whole subtree + const nodes = attr === "content" && inserted?.get(element); + if (!nodes || this.deps.positional) fanOut(element); + else if (!targetsHost) insertions.set(element, nodes); if (usesHas || hostReactsToChildren) childrenChanged(element); } else if (attr === "PROP") { // a JS property changed on `element`; `prop()` reads are on @@ -627,31 +690,62 @@ export class Rule { }); }); // find the most distant ancestors - outermostElements([...roots]).forEach((ancestor) => { - /* - * Scoped rules clamp to the host so fan-out from an ancestor - * above it (cross-sheet binding owners, providers above scope) - * never leaks into sibling scopes. Unscoped rules run in the - * root context, so a host-level fan-out (RUN_ALL) expands to the - * root. - */ - const queryRoot = this.isScoped - ? host.contains(ancestor) - ? ancestor - : host - : ancestor === host - ? (host.getRootNode() as Document | HTMLElement) - : ancestor; - if (targetsHost) { - if (queryRoot.contains(host)) elementsToMutate.add(host); - } else { - if (queryRoot instanceof Element) coverage?.add(queryRoot); - queryRoot.querySelectorAll(runSelector).forEach((el) => { - elementsToMutate.add(el as HTMLElement); - }); + planScans(outermostElements([...roots]), insertions).forEach( + ({ root: ancestor, accept }) => { + if (accept) { + // an insertion scan visits every match in its accepted nodes + accept.forEach((_, node) => coverage?.add(node)); + const direct = + accept.size <= DIRECT_SCAN_MAX + ? outermostElements([...accept.keys()] as HTMLElement[]).sort( + byDocumentOrder + ) + : null; + if (direct) { + // a node counts itself only when inserted (a merged full root does not) + direct.forEach((node) => { + if (accept.get(node) && node.matches(runSelector)) { + elementsToMutate.add(node); + } + node.querySelectorAll(runSelector).forEach((el) => { + elementsToMutate.add(el as HTMLElement); + }); + }); + } else { + ancestor.querySelectorAll(runSelector).forEach((el) => { + if (isAccepted(el, accept, ancestor)) { + elementsToMutate.add(el as HTMLElement); + } + }); + } + this.numberOfRuns++; + return; + } + /* + * Scoped rules clamp to the host so fan-out from an ancestor + * above it (cross-sheet binding owners, providers above scope) + * never leaks into sibling scopes. Unscoped rules run in the + * root context, so a host-level fan-out (RUN_ALL) expands to the + * root. + */ + const queryRoot = this.isScoped + ? host.contains(ancestor) + ? ancestor + : host + : ancestor === host + ? (host.getRootNode() as Document | HTMLElement) + : ancestor; + if (targetsHost) { + if (queryRoot.contains(host)) elementsToMutate.add(host); + } else { + if (queryRoot instanceof Element) coverage?.add(queryRoot); + queryRoot.querySelectorAll(runSelector).forEach((el) => { + elementsToMutate.add(el as HTMLElement); + }); + } + this.numberOfRuns++; } - this.numberOfRuns++; - }); + ); // execute mutations if (elementsToMutate.size > 0 && isInfoLogging()) { QuarkLogger.info({ diff --git a/packages/quark/src/types.ts b/packages/quark/src/types.ts index 56eeae9..52509bf 100644 --- a/packages/quark/src/types.ts +++ b/packages/quark/src/types.ts @@ -141,6 +141,9 @@ export interface ContextField extends ContextSheet { export type MutationMap = Map>; +/** `content` parent -> its inserted elements still below it; no entry = whole-subtree fan-out */ +export type InsertedNodes = Map; + export type ExpressionResult = | { type: "html"; diff --git a/packages/quark/support/docs-sections.json b/packages/quark/support/docs-sections.json index 9b537d1..be65a0b 100644 --- a/packages/quark/support/docs-sections.json +++ b/packages/quark/support/docs-sections.json @@ -49,7 +49,9 @@ "use", "on", "dispatch", - "view_transition" + "delay", + "view_transition", + "diagnostics" ] }, { diff --git a/packages/quark/support/docs/REACTIVITY.md b/packages/quark/support/docs/REACTIVITY.md index b97827f..22ef061 100644 --- a/packages/quark/support/docs/REACTIVITY.md +++ b/packages/quark/support/docs/REACTIVITY.md @@ -8,8 +8,8 @@ A rule runs when an element matches it and re-runs when something its selector o - a literal `attr("x")` in one of its values, when `x` changes on the matched element; - a `$binding` one of its values reads, when that binding changes on an ancestor-or-self owner (the nearest owner wins, so a farther change is ignored); - a literal `prop("x")` in one of its values, when JS assigns `element.x`; -- elements being inserted anywhere under the host, whether by Quark, an element, or app JS (a `childList` MutationObserver): rules matching the new elements run, `content` rules below the insertion point re-run, and `:has()` / `:empty` candidates above it are re-checked; -- elements being removed, only while some rule's match depends on children or sibling position (`:has()`, `:empty`, `:nth-child()`, `a + b`): the same re-runs as an insertion at that parent. Text-only changes are never observed. +- elements being inserted anywhere under the host, whether by Quark, an element, or app JS (a `childList` MutationObserver): rules run for the inserted elements and their descendants, not the rest of the parent's subtree, plus existing elements whose match depends on children or sibling position (`:has()` / `:empty` candidates above the insertion, `:nth-child()` / `a + b` / `a ~ b` subjects under its parent); an element inserted and removed again before Quark runs costs nothing; +- elements being removed, only while some rule's match depends on children or sibling position (`:has()`, `:empty`, `:nth-child()`, `a + b`): those rules re-run under that parent. Text-only changes are never observed. ## Not observed diff --git a/packages/quark/support/tests/__snapshots__/quark-features.test.ts.snap b/packages/quark/support/tests/__snapshots__/quark-features.test.ts.snap index 6775b54..43b91db 100644 --- a/packages/quark/support/tests/__snapshots__/quark-features.test.ts.snap +++ b/packages/quark/support/tests/__snapshots__/quark-features.test.ts.snap @@ -117,10 +117,10 @@ exports[`Quark features > content > injects markup via dangerous-html() > comple "getVar": 0, "importNode": 0, "listenerRuns": 0, - "matches": 1, + "matches": 2, "parentElement": 1, "quarkRuns": 2, - "queryScopeCost": 4, + "queryScopeCost": 3, "querySelectorAll": 2, "removeAttribute": 0, "ruleRuns": 2, @@ -271,10 +271,10 @@ exports[`Quark features > content > renders a DOM template via template(#id) > c "getVar": 0, "importNode": 2, "listenerRuns": 0, - "matches": 1, + "matches": 2, "parentElement": 1, "quarkRuns": 2, - "queryScopeCost": 5, + "queryScopeCost": 4, "querySelectorAll": 2, "removeAttribute": 0, "ruleRuns": 2, @@ -293,10 +293,10 @@ exports[`Quark features > content > renders a fetched template via template(url) "getVar": 0, "importNode": 2, "listenerRuns": 0, - "matches": 1, + "matches": 2, "parentElement": 1, "quarkRuns": 2, - "queryScopeCost": 4, + "queryScopeCost": 3, "querySelectorAll": 2, "removeAttribute": 0, "ruleRuns": 2, diff --git a/packages/quark/support/tests/__snapshots__/quark.test.ts.snap b/packages/quark/support/tests/__snapshots__/quark.test.ts.snap index 9969b52..4c5014b 100644 --- a/packages/quark/support/tests/__snapshots__/quark.test.ts.snap +++ b/packages/quark/support/tests/__snapshots__/quark.test.ts.snap @@ -73,10 +73,10 @@ exports[`Quark > mutates elements rendered after register > complexity 1`] = ` "getVar": 0, "importNode": 0, "listenerRuns": 0, - "matches": 0, - "parentElement": 0, + "matches": 1, + "parentElement": 1, "quarkRuns": 1, - "queryScopeCost": 2, + "queryScopeCost": 0, "querySelectorAll": 1, "removeAttribute": 0, "ruleRuns": 1, diff --git a/packages/quark/support/tests/__snapshots__/view-transition.view.test.ts.snap b/packages/quark/support/tests/__snapshots__/view-transition.view.test.ts.snap index 10a159a..4c2860d 100644 --- a/packages/quark/support/tests/__snapshots__/view-transition.view.test.ts.snap +++ b/packages/quark/support/tests/__snapshots__/view-transition.view.test.ts.snap @@ -2,19 +2,19 @@ exports[`view-transition view > adds and removes planets without the API, within the complexity budget > complexity 1`] = ` { - "attributeRuns": 7, - "closest": 6, + "attributeRuns": 5, + "closest": 4, "getVar": 4, "importNode": 2, "listenerRuns": 2, - "matches": 7, - "parentElement": 14, + "matches": 11, + "parentElement": 12, "quarkRuns": 3, - "queryScopeCost": 43, + "queryScopeCost": 27, "querySelectorAll": 7, "removeAttribute": 1, "ruleRuns": 10, - "schedulePaint": 7, + "schedulePaint": 5, "setAttribute": 3, "setVar": 1, "textContent": 1, diff --git a/packages/quark/support/tests/content-scope.test.ts b/packages/quark/support/tests/content-scope.test.ts new file mode 100644 index 0000000..d444223 --- /dev/null +++ b/packages/quark/support/tests/content-scope.test.ts @@ -0,0 +1,391 @@ +/** + * Insertions run rules for the inserted nodes only; existing matches re-run + * just for child / sibling-position deps, and nodes gone by run time cost nothing. + */ +import type { Quark } from "../../index"; +import { DIRECT_SCAN_MAX, planScans } from "../../src/rule"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "@excom/heft-rig/profiles/default/config/test-utils"; +import { + bypassSelectorCache, + flush, + mount, + unregisterAll, +} from "./helpers"; + +/** `tick(element)` records every element a declaration evaluated on. */ +const counter = () => { + const seen: Element[] = []; + const tick = vi.fn((el: Element) => { + seen.push(el); + return ""; + }); + return { tick, seen, ids: () => seen.map((el) => el.id || el.localName) }; +}; + +/** Counts the rules' fan-out queries from now on. */ +const spyQueries = (quark: Quark) => { + const qsa = vi.spyOn(Element.prototype, "querySelectorAll"); + return () => { + const selectors = new Set( + quark.rules.flatMap((r) => [r.matchSelector, r.scopedSelector()]) + ); + return qsa.mock.calls.filter(([s]) => selectors.has(s)).length; + }; +}; + +const el = (html: string) => { + const t = document.createElement("template"); + t.innerHTML = html; + return t.content.firstElementChild as HTMLElement; +}; + +describe("insertion scope", () => { + let restoreSelectorCache: () => void; + beforeAll(() => { + restoreSelectorCache = bypassSelectorCache(); + }); + afterAll(() => restoreSelectorCache()); + afterEach(() => { + unregisterAll(); + document.body.innerHTML = ""; + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("does not re-run existing matches when an unrelated element is inserted", async () => { + const { tick, seen } = counter(); + const { root, quark } = mount( + `

`, + `p { data-n: tick(element); } + :scope { data-host: tick(element); }`, + { tick } + ); + await flush(); + expect(seen).toHaveLength(3); + const queries = spyQueries(quark); + + root.append(document.createElement("aside")); + root.querySelector("#box")!.append(document.createElement("span")); + await flush(); + expect(seen).toHaveLength(3); + // `p`: the two inserted nodes are queried, never the host + expect(queries()).toBe(2); + }); + + it("runs the inserted subtree's own matches, nested ones included", async () => { + const { tick, ids } = counter(); + const { root } = mount(`

`, `p { data-n: tick(element); }`, { + tick, + }); + await flush(); + root.append( + el(`

`) + ); + root.append(el(`

`)); + await flush(); + expect(ids()).toEqual(["old", "x", "y", "w"]); + }); + + it("queries a few inserted nodes directly, never the parent's subtree", async () => { + const { tick, ids } = counter(); + const { root, quark } = mount( + `
${"

".repeat(20)}
`, + `p { data-n: tick(element); }`, + { tick } + ); + await flush(); + const qsa = vi.spyOn(Element.prototype, "querySelectorAll"); + root.querySelector("#big")!.append(el(`

`)); + await flush(); + expect(ids().slice(20)).toEqual(["x"]); + const sel = quark.rules[0].scopedSelector(); + const scanned = qsa.mock.calls.flatMap(([s], i) => + s === sel ? [qsa.mock.contexts[i]] : [] + ); + expect(scanned).toEqual([root.querySelector("#new")]); + }); + + it("scans the parent once when many nodes arrive together", async () => { + const { tick, seen } = counter(); + const { root, quark } = mount( + `

`, + `p { data-n: tick(element); }`, + { tick } + ); + await flush(); + const qsa = vi.spyOn(Element.prototype, "querySelectorAll"); + const big = root.querySelector("#big")!; + for (let i = 0; i <= DIRECT_SCAN_MAX; i++) big.append(el(`

`)); + await flush(); + expect(seen).toHaveLength(2 + DIRECT_SCAN_MAX); + const sel = quark.rules[0].scopedSelector(); + const scanned = qsa.mock.calls.flatMap(([s], i) => + s === sel ? [qsa.mock.contexts[i]] : [] + ); + expect(scanned).toEqual([big]); + }); + + it("merges nested insertions of one batch into one query", async () => { + const { tick, ids } = counter(); + const { root, quark } = mount( + `

`, + `p { data-n: tick(element); }`, + { tick } + ); + await flush(); + const queries = spyQueries(quark); + const outer = el(`

`); + root.append(outer); + outer.append(el(`

`)); + await flush(); + expect(ids()).toEqual(["old", "x", "y"]); + expect(queries()).toBe(1); + }); + + it("runs nothing for an element inserted and removed in the same tick", async () => { + const { tick, seen } = counter(); + const { root, quark } = mount( + `

`, + `p { data-n: tick(element); } + button { data-proxy: tick(element); }`, + { tick } + ); + await flush(); + expect(seen).toHaveLength(1); + const runs = quark.rules.map((r) => r.numberOfRuns); + const queries = spyQueries(quark); + + // Neutron's invoker proxy for `show-modal` / `toggle-popover` + const proxy = document.createElement("button"); + proxy.setAttribute("command", "show-modal"); + proxy.setAttribute("commandfor", "d"); + root.append(proxy); + proxy.click(); + proxy.remove(); + await flush(); + + expect(seen).toHaveLength(1); + expect(quark.rules.map((r) => r.numberOfRuns)).toEqual(runs); + expect(queries()).toBe(0); + }); + + it("falls back to the whole subtree for a content entry queued without nodes", async () => { + const { tick, ids } = counter(); + const { root, quark } = mount( + `

`, + `p { data-n: tick(element); }`, + { tick } + ); + await flush(); + root.append(el(`

`)); + // same batch: the observer's scoped entry and a direct one (iterate requeue) + quark.queueRunRules({ element: root, attribute: "content" }); + await flush(); + expect(ids().slice(2).sort()).toEqual(["a", "b", "c"]); + + // direct first, observer second: still the whole subtree + quark.queueRunRules({ element: root, attribute: "content" }); + quark.queueRunRules({ element: root, attribute: "content", added: [] }); + await flush(); + expect(ids().slice(5).sort()).toEqual(["a", "b", "c"]); + }); + + it("keeps one query when inserted rows are also requeued as NEW_SELF", async () => { + const { tick, ids } = counter(); + const { root, quark } = mount( + `
`, + `li { data-n: tick(element); }`, + { tick } + ); + await flush(); + const ul = root.querySelector("ul")!; + const queries = spyQueries(quark); + const rowIds = Array.from({ length: DIRECT_SCAN_MAX + 1 }, (_, i) => `r${i}`); + const rows = rowIds.map((id) => el(`
  • `)); + ul.append(...rows); + // what iterate()'s `after` hook does for changed rows + rows.forEach((row) => + quark.queueRunRules({ element: row, attribute: "NEW_SELF" }) + ); + await flush(); + expect(ids().slice(2)).toEqual(rowIds); + expect(queries()).toBe(1); + }); + + it("lets a fan-out below the insertion parent ride along, without its root", async () => { + const { tick, ids } = counter(); + const { root, quark } = mount( + `
    `, + `div[data-on] div { data-n: tick(element); }`, + { tick } + ); + await flush(); + expect(ids()).toEqual(["d", "inner"]); + const queries = spyQueries(quark); + // one batch: `d` fans out over its subtree, a new element lands beside + root.querySelector("#d")!.setAttribute("data-on", ""); + root.append(el(`
    `)); + await flush(); + expect(ids().slice(2)).toEqual(["inner", "n2"]); + // `d` (descendants only) and `new` are queried directly, in document order + expect(queries()).toBe(2); + }); + + it("covers defs written inside an insertion, so the sheet does not re-run for its own write", async () => { + const { root, quark } = mount( + ``, + `article { $t: attr("data-t"); p { content: $t; } }` + ); + await flush(); + const run = vi.spyOn(quark, "run"); + root.append(el(`

    `)); + await flush(); + expect(root.querySelector("p")!.textContent).toBe("hi"); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("scopes insertions for global sheets too", async () => { + const { tick, ids } = counter(); + const { root } = mount( + `

    `, + `section p { data-n: tick(element); }`, + { tick }, + { isScoped: false } + ); + await flush(); + root.append(el(`

    `)); + await flush(); + expect(ids()).toEqual(["a", "b"]); + }); + + describe("selectors that depend on children or position still flip existing matches", () => { + const cases: Array<[string, string, string, (root: HTMLElement) => void, string]> = [ + [ + ":has()", + `
      `, + `ul:has(li) { data-on: ""; }`, + (root) => root.querySelector("ul")!.append(document.createElement("li")), + "#t", + ], + [ + ":empty", + `
        `, + `ul:not(:empty) { data-on: ""; }`, + (root) => root.querySelector("ul")!.append(document.createElement("li")), + "#t", + ], + [ + ":nth-child()", + `
        `, + `li:nth-child(2) { data-on: ""; }`, + (root) => root.querySelector("ul")!.prepend(document.createElement("li")), + "#t", + ], + [ + "a + b", + `

        `, + `h2 + p { data-on: ""; }`, + (root) => + root.querySelector("article")!.prepend(document.createElement("h2")), + "#t", + ], + [ + "a ~ b", + `

        `, + `h2 ~ p span { data-on: ""; }`, + (root) => + root.querySelector("article")!.prepend(document.createElement("h2")), + "#t span", + ], + [ + ":scope:has()", + `

        `, + `:scope:has(li) p { data-on: ""; }`, + (root) => + root.querySelector("article")!.append(document.createElement("li")), + "p", + ], + ]; + it.each(cases)("%s", async (_, html, src, insert, target) => { + const { root } = mount(html, src); + await flush(); + const t = root.querySelector(target)!; + expect(t.hasAttribute("data-on")).toBe(false); + insert(root); + await flush(); + expect(t.hasAttribute("data-on")).toBe(true); + }); + }); + + it("does not restart an ungated @delay on an unrelated insertion", async () => { + vi.useFakeTimers(); + const { root } = mount( + ``, + `#notice { @delay 3000 { is-open: ""; } }` + ); + // nested 0ms timers advance the fake clock by 1ms each: leave slack + const tick = (ms: number) => vi.advanceTimersByTimeAsync(ms); + const notice = root.querySelector("#notice")!; + await tick(2000); + // a popover opening: proxy button in and out, plus a lasting insertion + const proxy = document.createElement("button"); + root.append(proxy); + proxy.remove(); + root.append(document.createElement("div")); + await tick(50); + expect(notice.hasAttribute("is-open")).toBe(false); + // a restart would fire at ~5050 + await tick(1000); + expect(notice.hasAttribute("is-open")).toBe(true); + }); +}); + +describe("planScans", () => { + const tree = () => { + const root = el( + `

        ` + ); + const $ = (id: string) => root.querySelector(`#${id}`) as HTMLElement; + return { root, $ }; + }; + + it("drops an insertion a full root already covers", () => { + const { $ } = tree(); + expect(planScans([$("s")], new Map([[$("d"), [$("p")]]]))).toEqual([ + { root: $("s"), accept: null }, + ]); + }); + + it("merges nested insertions and full roots below a scanned parent", () => { + const { root, $ } = tree(); + const [scan, ...rest] = planScans( + [$("n"), $("d")], + new Map([ + [root as HTMLElement, [$("s")]], + [$("s"), [$("d")]], + ]) + ); + expect(rest).toEqual([]); + expect(scan.root).toBe(root); + expect([...scan.accept!]).toEqual([ + [$("s"), true], + [$("d"), true], + [$("n"), false], + ]); + }); + + it("skips a parent whose nodes are all gone", () => { + const { $ } = tree(); + expect(planScans([$("n")], new Map([[$("s"), []]]))).toEqual([ + { root: $("n"), accept: null }, + ]); + }); +}); diff --git a/packages/quark/support/tests/internals.test.ts b/packages/quark/support/tests/internals.test.ts index 520303b..f5122f1 100644 --- a/packages/quark/support/tests/internals.test.ts +++ b/packages/quark/support/tests/internals.test.ts @@ -209,12 +209,33 @@ describe("observer", () => { host.setAttribute("data-x", "1"); host.setAttribute("data-other", "1"); host.appendChild(document.createTextNode("t")); - host.appendChild(document.createElement("span")); + const span = document.createElement("span"); + host.append(document.createTextNode("u"), span); await wait(0); expect(cb.mock.calls.map(([arg]) => arg.attribute)).toEqual([ "data-x", "content", ]); + // the inserted elements ride along, text nodes do not + expect(cb.mock.calls[1][0].added).toEqual([span]); + observer.disconnect(); + host.remove(); + }); + + it("reports element removals only when asked, text-only ones never", async () => { + const host = document.createElement("div"); + host.innerHTML = "t"; + document.body.appendChild(host); + const cb = vi.fn(); + const observer = observe(host, [], cb, { childRemovals: true })!; + host.firstChild!.remove(); + await wait(0); + expect(cb).not.toHaveBeenCalled(); + host.querySelector("span")!.remove(); + await wait(0); + expect(cb.mock.calls.map(([arg]) => arg.attribute)).toEqual([ + "CHILD_REMOVED", + ]); observer.disconnect(); host.remove(); }); diff --git a/packages/super-form/support/demos/external-trigger.html b/packages/super-form/support/demos/external-trigger.html index 9a4f25b..c3eb56a 100644 --- a/packages/super-form/support/demos/external-trigger.html +++ b/packages/super-form/support/demos/external-trigger.html @@ -15,9 +15,20 @@ + + @use "quark:util" as *; + + super-form[is-success] { + $res: prop("provision"); + li { content: "#{index}: #{to-json(item)}"; } + ul:first-of-type { content: iterate($res.body.json, "#demo-super-form-item"); } + ul:last-of-type { content: iterate($res, "#demo-super-form-item"); } + } + - - super-form[is-success] { - $res: prop("provision"); - li { content: "#{index}: #{item}"; } - ul:first-of-type { content: iterate($res.body.json, "#demo-super-form-item"); } - ul:last-of-type { content: iterate($res, "#demo-super-form-item"); } - } - diff --git a/packages/super-form/support/tests/external-trigger.view.test.ts b/packages/super-form/support/tests/external-trigger.view.test.ts index 405d1de..adb0ac2 100644 --- a/packages/super-form/support/tests/external-trigger.view.test.ts +++ b/packages/super-form/support/tests/external-trigger.view.test.ts @@ -51,7 +51,16 @@ describe("external-trigger view", () => { const budget = meter.take(); meter.stop(); expect(form.hasAttribute("is-success")).toBe(true); - expect(root.querySelector("ul")?.textContent).toMatch(/Ada/); + const rows = (list: string) => + [...root.querySelectorAll(`${list} li`)].map((li) => li.textContent); + // rows print `index: to-json(item)`: strings quoted, objects as JSON + expect(rows("ul:first-of-type")).toEqual(['nickname: "Ada"']); + expect(rows("ul:last-of-type")).toEqual( + expect.arrayContaining([ + "status: 200", + 'body: {"json":{"nickname":"Ada"}}', + ]), + ); expectComplexity(budget); }); }); diff --git a/packages/super-input/support/demos/comprehensive.html b/packages/super-input/support/demos/comprehensive.html index 4e5b9cc..f5f41d3 100644 --- a/packages/super-input/support/demos/comprehensive.html +++ b/packages/super-input/support/demos/comprehensive.html @@ -6,7 +6,7 @@ class="raised" > - + diff --git a/packages/super-input/support/demos/invalid-message.html b/packages/super-input/support/demos/invalid-message.html index 18be73a..fd29bb8 100644 --- a/packages/super-input/support/demos/invalid-message.html +++ b/packages/super-input/support/demos/invalid-message.html @@ -1,7 +1,7 @@
        - +
        \ No newline at end of file diff --git a/packages/super-input/support/demos/slider.html b/packages/super-input/support/demos/slider.html index af05a11..a66c2a8 100644 --- a/packages/super-input/support/demos/slider.html +++ b/packages/super-input/support/demos/slider.html @@ -1,4 +1,10 @@ - - - - \ No newline at end of file +
        + + + + + + + super-input { --super-input-value: attr("current-value"); } + +
        \ No newline at end of file diff --git a/packages/super-input/support/demos/with-format.html b/packages/super-input/support/demos/with-format.html index 2fa40a0..8f864d0 100644 --- a/packages/super-input/support/demos/with-format.html +++ b/packages/super-input/support/demos/with-format.html @@ -1,4 +1,4 @@ - + diff --git a/packages/super-input/support/tests/comprehensive.view.test.ts b/packages/super-input/support/tests/comprehensive.view.test.ts index a39dcc1..36c55de 100644 --- a/packages/super-input/support/tests/comprehensive.view.test.ts +++ b/packages/super-input/support/tests/comprehensive.view.test.ts @@ -18,5 +18,7 @@ describe("comprehensive view", () => { expect(host.hasAttribute("auto-label")).toBe(true); expect(host.getAttribute("text-format")).toBe("(xxx) xxx-xxxx"); expect(host.getAttribute("invalid-message")).toMatch(/US phone/); + // digits-only keypad on touch devices + expect(host.querySelector("input")?.inputMode).toBe("numeric"); }); }); diff --git a/packages/super-input/support/tests/invalid-message.view.test.ts b/packages/super-input/support/tests/invalid-message.view.test.ts index 91a5d14..05b580d 100644 --- a/packages/super-input/support/tests/invalid-message.view.test.ts +++ b/packages/super-input/support/tests/invalid-message.view.test.ts @@ -17,5 +17,6 @@ describe("invalid-message view", () => { const host = root.querySelector("super-input")!; expect(host.getAttribute("invalid-message")).toMatch(/US phone/); expect(host.querySelector("input")?.required).toBe(true); + expect(host.querySelector("input")?.inputMode).toBe("numeric"); }); }); diff --git a/packages/super-input/support/tests/slider.view.test.ts b/packages/super-input/support/tests/slider.view.test.ts index 4fa33eb..0145c10 100644 --- a/packages/super-input/support/tests/slider.view.test.ts +++ b/packages/super-input/support/tests/slider.view.test.ts @@ -14,11 +14,12 @@ describe("slider view", () => { it("reflects the range value", async () => { const { root } = await mountView(readDemo(import.meta.url, "slider")); + const superInput = root.querySelector("super-input")!; const input = root.querySelector("input")!; expect(input.value).toBe("36"); input.value = "40"; input.dispatchEvent(new Event("input", { bubbles: true })); await flush(); - expect(root.getAttribute("current-value")).toBe("40"); + expect(superInput.getAttribute("current-value")).toBe("40"); }); }); diff --git a/packages/super-input/support/tests/with-format.view.test.ts b/packages/super-input/support/tests/with-format.view.test.ts index 4faad36..d5cb258 100644 --- a/packages/super-input/support/tests/with-format.view.test.ts +++ b/packages/super-input/support/tests/with-format.view.test.ts @@ -16,5 +16,6 @@ describe("with-format view", () => { const { root } = await mountView(readDemo(import.meta.url, "with-format")); const input = root.querySelector("input")!; expect(input.value).toMatch(/\(\d{3}\)/); + expect(input.inputMode).toBe("numeric"); }); }); From 71488fc1246b3bcba393ef6ea7a5b1e35166aab3 Mon Sep 17 00:00:00 2001 From: joe <133191653+excom-dev@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:28:16 -0500 Subject: [PATCH 4/6] Eliminate Quark feature, revise docs --- .vscode/settings.json | 3 + README.md | 2 +- .../view-dist_2026-09-28-demos.json | 10 + .../view-dist_2026-09-28-demos.json | 10 + .../view-dist_2026-09-28-demos.json | 10 + ...dist_2026-09-28-quark-module-contract.json | 15 ++ .../view-dist_2026-09-28-docs.json | 10 + .../view-dist_2026-09-28-demos.json | 10 + .../view-dist_2026-09-28-demos.json | 10 + .../view-dist_2026-09-28-demos.json | 10 + .../view-dist_2026-09-28-docs.json | 10 + .../view-dist_2026-09-28-docs.json | 10 + .../view-dist_2026-09-28-module-contract.json | 25 +++ .../view-dist_2026-09-28-demos.json | 10 + .../view-dist_2026-09-28-demos.json | 10 + .../view-dist_2026-09-28-demos.json | 10 + .../detect-browser/support/demos/safari.html | 12 +- .../detect-browser/support/docs/README.md | 4 +- .../support/tests/safari.view.test.ts | 34 ++-- packages/docs-site/public/demo-utils.css | 6 +- packages/docs-site/public/demo-utils.ts | 115 ++++------- .../docs/ADAPTER_STATE_ORCHESTRATOR.md | 6 +- .../docs-site/support/docs/BEST_PRACTICES.md | 6 +- .../docs-site/support/docs/BUSINESS_LOGIC.md | 19 +- .../docs-site/support/docs/CORE_CONCEPTS.md | 8 +- .../support/docs/CREATING_ELEMENTS.md | 2 +- packages/docs-site/support/docs/GLOSSARY.md | 2 +- .../docs-site/support/docs/INTRODUCTION.md | 2 +- .../docs-site/support/docs/LIMITATIONS.md | 16 +- .../docs-site/support/docs/ORCHESTRATING.md | 55 ++++-- .../docs-site/support/docs/ORIGIN_STORY.md | 4 +- packages/docs-site/support/docs/PRIOR_ART.md | 2 +- .../docs-site/support/docs/QUICK_START.md | 20 +- packages/docs-site/support/docs/ROADMAP.md | 1 + packages/docs-site/support/docs/STYLING.md | 2 +- .../docs-site/support/docs/TROUBLESHOOTING.md | 20 +- .../support/tests/demo-utils.test.ts | 178 +++++------------ .../dom-observer/support/demos/simple.html | 10 +- .../__snapshots__/simple.view.test.ts.snap | 8 +- .../support/tests/simple.view.test.ts | 15 +- .../event-handler/support/demos/debounce.html | 34 ++-- .../support/demos/fire-event.html | 16 +- .../event-handler/support/demos/retarget.html | 4 +- .../__snapshots__/debounce.view.test.ts.snap | 8 +- .../fire-event.view.test.ts.snap | 6 +- .../__snapshots__/retarget.view.test.ts.snap | 4 +- .../support/tests/debounce.view.test.ts | 30 +-- .../support/tests/fire-event.view.test.ts | 7 +- .../support/tests/retarget.view.test.ts | 7 +- .../support/docs/BREAKING_CHANGES.md | 4 +- .../support/docs/README.md | 13 +- .../syntaxes/quark.tmLanguage.json | 6 +- .../support/demos/request.html | 15 +- .../__snapshots__/request.view.test.ts.snap | 23 +++ .../support/tests/request.view.test.ts | 62 ++++-- .../support/demos/request.html | 17 +- .../__snapshots__/request.view.test.ts.snap | 23 +++ .../support/tests/request.view.test.ts | 39 ++-- .../support/demos/simple.html | 16 +- .../__snapshots__/simple.view.test.ts.snap | 26 +-- .../support/tests/simple.view.test.ts | 33 +++- packages/quark-formatter/package.json | 4 +- packages/quark-formatter/src/printer.ts | 12 +- .../quark-formatter/support/docs/README.md | 10 +- packages/quark-parser/src/parser.ts | 9 +- packages/quark-parser/src/tables.ts | 4 +- packages/quark-parser/src/types.ts | 9 +- packages/quark-parser/support/docs/README.md | 64 +++---- packages/quark/src/builtin-modules.ts | 11 +- packages/quark/src/constants.ts | 19 ++ packages/quark/src/devtools-hook.ts | 2 +- packages/quark/src/language.ts | 82 ++++---- packages/quark/src/properties.ts | 29 +-- packages/quark/src/quark.ts | 2 +- packages/quark/src/resolvers.ts | 27 ++- packages/quark/src/types.ts | 4 +- packages/quark/src/utils.ts | 4 +- packages/quark/src/variables.ts | 42 ++-- packages/quark/support/demos/events.html | 8 +- packages/quark/support/demos/iterate.html | 7 +- packages/quark/support/demos/js-api.html | 20 +- .../quark/support/demos/view-transition.html | 5 +- packages/quark/support/docs/AT_RULES.md | 14 +- packages/quark/support/docs/BUILTINS.md | 6 +- packages/quark/support/docs/CONTENT.md | 17 +- packages/quark/support/docs/DECLARATIONS.md | 2 +- packages/quark/support/docs/DISPATCH.md | 2 +- .../quark/support/docs/ELEMENT_PROPERTIES.md | 7 +- packages/quark/support/docs/EXPRESSIONS.md | 2 +- packages/quark/support/docs/INTERNAL.md | 4 +- packages/quark/support/docs/JS_API.md | 2 +- packages/quark/support/docs/JS_WRITES.md | 25 ++- packages/quark/support/docs/LIMITATIONS.md | 2 +- packages/quark/support/docs/MODULES.md | 4 +- packages/quark/support/docs/ON.md | 26 ++- packages/quark/support/docs/README.md | 12 +- packages/quark/support/docs/SHEETS.md | 4 +- packages/quark/support/docs/SYNTAX.md | 10 +- packages/quark/support/docs/USE.md | 81 +++++++- .../__snapshots__/events.view.test.ts.snap | 10 +- .../__snapshots__/js-api.view.test.ts.snap | 12 +- .../__snapshots__/quark-features.test.ts.snap | 4 +- .../view-transition.view.test.ts.snap | 2 +- packages/quark/support/tests/actions.test.ts | 33 ++++ .../quark/support/tests/events.view.test.ts | 10 +- .../quark/support/tests/iterate.view.test.ts | 5 - .../quark/support/tests/js-api.view.test.ts | 35 +++- .../support/tests/quark-features.test.ts | 180 ++++++++++++++++-- .../quark/support/tests/resolvers.test.ts | 97 +++++++--- .../support/tests/view-transition.test.ts | 59 ++++-- .../tests/view-transition.view.test.ts | 5 - .../support/demos/persist-content.html | 28 ++- .../support/demos/render-event.html | 38 ++-- .../persist-content.view.test.ts.snap | 23 +++ .../render-event.view.test.ts.snap | 23 +++ .../tests/persist-content.view.test.ts | 44 +++-- .../support/tests/render-event.view.test.ts | 54 +++++- packages/super-form/support/demos/simple.html | 2 +- .../support/tests/slider.view.test.ts | 1 + 119 files changed, 1518 insertions(+), 846 deletions(-) create mode 100644 common/changes/@excom/detect-browser/view-dist_2026-09-28-demos.json create mode 100644 common/changes/@excom/dom-observer/view-dist_2026-09-28-demos.json create mode 100644 common/changes/@excom/event-handler/view-dist_2026-09-28-demos.json create mode 100644 common/changes/@excom/nucleus-kit/view-dist_2026-09-28-quark-module-contract.json create mode 100644 common/changes/@excom/nucleus-quark-highlighter/view-dist_2026-09-28-docs.json create mode 100644 common/changes/@excom/provider-geolocation/view-dist_2026-09-28-demos.json create mode 100644 common/changes/@excom/provider-orientation/view-dist_2026-09-28-demos.json create mode 100644 common/changes/@excom/provider-storage/view-dist_2026-09-28-demos.json create mode 100644 common/changes/@excom/quark-formatter/view-dist_2026-09-28-docs.json create mode 100644 common/changes/@excom/quark-parser/view-dist_2026-09-28-docs.json create mode 100644 common/changes/@excom/quark/view-dist_2026-09-28-module-contract.json create mode 100644 common/changes/@excom/renderable-element/view-dist_2026-09-28-demos.json create mode 100644 common/changes/@excom/super-form/view-dist_2026-09-28-demos.json create mode 100644 common/changes/@excom/super-input/view-dist_2026-09-28-demos.json create mode 100644 packages/provider-geolocation/support/tests/__snapshots__/request.view.test.ts.snap create mode 100644 packages/provider-orientation/support/tests/__snapshots__/request.view.test.ts.snap create mode 100644 packages/renderable-element/support/tests/__snapshots__/persist-content.view.test.ts.snap create mode 100644 packages/renderable-element/support/tests/__snapshots__/render-event.view.test.ts.snap diff --git a/.vscode/settings.json b/.vscode/settings.json index 2216de0..f7ec3c9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,6 +20,7 @@ "**/packages/*/tsconfig.json": true, "**/packages/*/custom-elements.json": true, "**/packages/*/package-meta.json": true, + "**/packages/*/README.md": true, "**/dist-docs": true, }, @@ -43,6 +44,7 @@ "**/packages/*/tsconfig.json": true, "**/packages/**/custom-elements.json": true, "**/packages/**/package-meta.json": true, + "**/packages/*/README.md": true, "**/dist-docs": true, }, @@ -63,6 +65,7 @@ "**/packages/*/tsconfig.json": true, "**/packages/**/custom-elements.json": true, "**/packages/**/package-meta.json": true, + "**/packages/*/README.md": true, "**/dist-docs": true, }, diff --git a/README.md b/README.md index 6785736..c6810b0 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Your HTML __*is*__ the app! Drop-in custom elements that each have a single resp - **Back to the future** Welcome back to building static HTML5 apps. A break from complex JavaScript apps that compile to HTML. - **Little to no JavaScript** You no longer need to write JS for the vast majority of UI cases. You may still call-out to your own pure functions for complex cases. -- **Native++** Just HTML with a derivative of CSS, named Quark, sprinkled on top. +- **Native++** It's real HTML. With a separate, synergistic guest: Quark. - **No magic** No special frameworks, build processes, rendering wizardry, or "HTML-in-my-JS" / "JS-in-my-HTML" DSLs. - **Progressively enhanced** Drop into existing static/server-side-rendered sites. Neutron and Quark can also be used independently. - **Fully composable** Templates, templating, behavior, and custom logic are all decoupled & robust. diff --git a/common/changes/@excom/detect-browser/view-dist_2026-09-28-demos.json b/common/changes/@excom/detect-browser/view-dist_2026-09-28-demos.json new file mode 100644 index 0000000..a2e38ec --- /dev/null +++ b/common/changes/@excom/detect-browser/view-dist_2026-09-28-demos.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@excom/detect-browser", + "comment": "Update the Safari demo to show an install hint instead of loading polyfills", + "type": "none" + } + ], + "packageName": "@excom/detect-browser" +} diff --git a/common/changes/@excom/dom-observer/view-dist_2026-09-28-demos.json b/common/changes/@excom/dom-observer/view-dist_2026-09-28-demos.json new file mode 100644 index 0000000..482724a --- /dev/null +++ b/common/changes/@excom/dom-observer/view-dist_2026-09-28-demos.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@excom/dom-observer", + "comment": "Update the demos to write State from `@on` blocks and `prop(\"provision\")` instead of JS handlers", + "type": "none" + } + ], + "packageName": "@excom/dom-observer" +} diff --git a/common/changes/@excom/event-handler/view-dist_2026-09-28-demos.json b/common/changes/@excom/event-handler/view-dist_2026-09-28-demos.json new file mode 100644 index 0000000..4e92ca2 --- /dev/null +++ b/common/changes/@excom/event-handler/view-dist_2026-09-28-demos.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@excom/event-handler", + "comment": "Update the demos to write State from `@on` blocks and `prop(\"provision\")` instead of JS handlers", + "type": "none" + } + ], + "packageName": "@excom/event-handler" +} diff --git a/common/changes/@excom/nucleus-kit/view-dist_2026-09-28-quark-module-contract.json b/common/changes/@excom/nucleus-kit/view-dist_2026-09-28-quark-module-contract.json new file mode 100644 index 0000000..9f7a197 --- /dev/null +++ b/common/changes/@excom/nucleus-kit/view-dist_2026-09-28-quark-module-contract.json @@ -0,0 +1,15 @@ +{ + "changes": [ + { + "packageName": "@excom/nucleus-kit", + "comment": "Remove promise support from `content:` in Quark sheets: a module function returns a value or a node (see `Asynchronous work` in the Quark docs)", + "type": "minor" + }, + { + "packageName": "@excom/nucleus-kit", + "comment": "Update `handle:` in `@on`: `event` and `target` are not in scope inside a `handle:` expression, and the listener receives the event", + "type": "minor" + } + ], + "packageName": "@excom/nucleus-kit" +} diff --git a/common/changes/@excom/nucleus-quark-highlighter/view-dist_2026-09-28-docs.json b/common/changes/@excom/nucleus-quark-highlighter/view-dist_2026-09-28-docs.json new file mode 100644 index 0000000..d7ca2b6 --- /dev/null +++ b/common/changes/@excom/nucleus-quark-highlighter/view-dist_2026-09-28-docs.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@excom/nucleus-quark-highlighter", + "comment": "Update the README and fix its `provider-fetch` sample", + "type": "none" + } + ], + "packageName": "@excom/nucleus-quark-highlighter" +} diff --git a/common/changes/@excom/provider-geolocation/view-dist_2026-09-28-demos.json b/common/changes/@excom/provider-geolocation/view-dist_2026-09-28-demos.json new file mode 100644 index 0000000..cf934e3 --- /dev/null +++ b/common/changes/@excom/provider-geolocation/view-dist_2026-09-28-demos.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@excom/provider-geolocation", + "comment": "Update the demos to write State from `@on` blocks and `prop(\"provision\")` instead of JS handlers", + "type": "none" + } + ], + "packageName": "@excom/provider-geolocation" +} diff --git a/common/changes/@excom/provider-orientation/view-dist_2026-09-28-demos.json b/common/changes/@excom/provider-orientation/view-dist_2026-09-28-demos.json new file mode 100644 index 0000000..5c9dc2e --- /dev/null +++ b/common/changes/@excom/provider-orientation/view-dist_2026-09-28-demos.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@excom/provider-orientation", + "comment": "Update the demos to write State from `@on` blocks and `prop(\"provision\")` instead of JS handlers", + "type": "none" + } + ], + "packageName": "@excom/provider-orientation" +} diff --git a/common/changes/@excom/provider-storage/view-dist_2026-09-28-demos.json b/common/changes/@excom/provider-storage/view-dist_2026-09-28-demos.json new file mode 100644 index 0000000..cf58364 --- /dev/null +++ b/common/changes/@excom/provider-storage/view-dist_2026-09-28-demos.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@excom/provider-storage", + "comment": "Update the demos to write State from `@on` blocks and `prop(\"provision\")` instead of JS handlers", + "type": "none" + } + ], + "packageName": "@excom/provider-storage" +} diff --git a/common/changes/@excom/quark-formatter/view-dist_2026-09-28-docs.json b/common/changes/@excom/quark-formatter/view-dist_2026-09-28-docs.json new file mode 100644 index 0000000..e469e7e --- /dev/null +++ b/common/changes/@excom/quark-formatter/view-dist_2026-09-28-docs.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@excom/quark-formatter", + "comment": "Update the README samples, the package description and keywords", + "type": "none" + } + ], + "packageName": "@excom/quark-formatter" +} diff --git a/common/changes/@excom/quark-parser/view-dist_2026-09-28-docs.json b/common/changes/@excom/quark-parser/view-dist_2026-09-28-docs.json new file mode 100644 index 0000000..bd89caf --- /dev/null +++ b/common/changes/@excom/quark-parser/view-dist_2026-09-28-docs.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@excom/quark-parser", + "comment": "Update the README samples, the wording on how Quark relates to CSS, and the type comments", + "type": "none" + } + ], + "packageName": "@excom/quark-parser" +} diff --git a/common/changes/@excom/quark/view-dist_2026-09-28-module-contract.json b/common/changes/@excom/quark/view-dist_2026-09-28-module-contract.json new file mode 100644 index 0000000..9fe5fd5 --- /dev/null +++ b/common/changes/@excom/quark/view-dist_2026-09-28-module-contract.json @@ -0,0 +1,25 @@ +{ + "changes": [ + { + "packageName": "@excom/quark", + "comment": "Remove promise support from `content:`. A promise that a module function returns to `content:` is refused with a console error and the content stays as it was; return a value or a node instead. `template()`, `iterate()` and `@view-transition (until: …)` wait as before.", + "type": "minor" + }, + { + "packageName": "@excom/quark", + "comment": "Update `handle:` in `@on` to name the listener: `event` and `target` are not in scope inside a `handle:` expression. A call in `handle:` runs on every event and must return the listener, which receives the event with `this` being the element.", + "type": "minor" + }, + { + "packageName": "@excom/quark", + "comment": "Add a console warning when `handle:` evaluates to a value that is not a function", + "type": "patch" + }, + { + "packageName": "@excom/quark", + "comment": "Update the docs: modules as pure business logic, an `Asynchronous work` guide in `@use`, and one wording for how Quark relates to CSS", + "type": "none" + } + ], + "packageName": "@excom/quark" +} diff --git a/common/changes/@excom/renderable-element/view-dist_2026-09-28-demos.json b/common/changes/@excom/renderable-element/view-dist_2026-09-28-demos.json new file mode 100644 index 0000000..60bfda5 --- /dev/null +++ b/common/changes/@excom/renderable-element/view-dist_2026-09-28-demos.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@excom/renderable-element", + "comment": "Update the demos to switch content from `@on` blocks instead of inline scripts", + "type": "none" + } + ], + "packageName": "@excom/renderable-element" +} diff --git a/common/changes/@excom/super-form/view-dist_2026-09-28-demos.json b/common/changes/@excom/super-form/view-dist_2026-09-28-demos.json new file mode 100644 index 0000000..e969458 --- /dev/null +++ b/common/changes/@excom/super-form/view-dist_2026-09-28-demos.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@excom/super-form", + "comment": "Update the demos", + "type": "none" + } + ], + "packageName": "@excom/super-form" +} diff --git a/common/changes/@excom/super-input/view-dist_2026-09-28-demos.json b/common/changes/@excom/super-input/view-dist_2026-09-28-demos.json new file mode 100644 index 0000000..8a0a3db --- /dev/null +++ b/common/changes/@excom/super-input/view-dist_2026-09-28-demos.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@excom/super-input", + "comment": "Update the demos and their tests", + "type": "none" + } + ], + "packageName": "@excom/super-input" +} diff --git a/packages/detect-browser/support/demos/safari.html b/packages/detect-browser/support/demos/safari.html index e6213df..50e44ad 100644 --- a/packages/detect-browser/support/demos/safari.html +++ b/packages/detect-browser/support/demos/safari.html @@ -1,11 +1,15 @@
        Spoof an iOS Safari user agent in DevTools and reload. + + + - @use "/demo-utils" as *; - - detect-browser[browser-name="safari"] { - content: loadPolyfills(prop("provision")); + detect-browser[browser-name="safari"][operating-system="ios"]:not([is-standalone]) + include-content { + is-active: ""; }
        diff --git a/packages/detect-browser/support/docs/README.md b/packages/detect-browser/support/docs/README.md index d888856..a4bd2fb 100644 --- a/packages/detect-browser/support/docs/README.md +++ b/packages/detect-browser/support/docs/README.md @@ -44,8 +44,8 @@ Showing a greeting in your system language (Only Spanish or English for this exa -#### Loading platform-specific polyfills +#### Safari-only install hint -In this demo, when the browser is Safari, polyfills are loaded. Spoof an iOS Safari user agent in DevTools and reload to trigger it. It will also `console.log` the full device info. +iOS Safari has no install prompt, so the sheet renders "Add to Home Screen" steps there, and not once the app is installed. Spoof an iOS Safari user agent in DevTools and reload to trigger it. diff --git a/packages/detect-browser/support/tests/safari.view.test.ts b/packages/detect-browser/support/tests/safari.view.test.ts index ed8866d..c2ac8d4 100644 --- a/packages/detect-browser/support/tests/safari.view.test.ts +++ b/packages/detect-browser/support/tests/safari.view.test.ts @@ -1,29 +1,39 @@ +import "@excom/include-content"; import "@excom/quark-sheet"; import "../../index"; import { afterEach, - beforeEach, describe, expect, it, + vi, } from "@excom/heft-rig/profiles/default/config/test-utils"; -import { - installDemoModules, - mountView, - readDemo, - restoreDemoModules, -} from "@excom/quark/support/tests/view-helpers"; +import { mountView, readDemo } from "@excom/quark/support/tests/view-helpers"; + +const IOS_SAFARI = + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"; describe("safari view", () => { - beforeEach(() => installDemoModules()); afterEach(() => { document.body.innerHTML = ""; - restoreDemoModules(); + vi.restoreAllMocks(); + }); + + it("renders the install hint on iOS Safari", async () => { + vi.spyOn(navigator, "userAgent", "get").mockReturnValue(IOS_SAFARI); + const { root } = await mountView(readDemo(import.meta.url, "safari")); + const include = root.querySelector("include-content")!; + expect(include.hasAttribute("is-active")).toBe(true); + expect(include.textContent).toMatch(/Add to Home Screen/); }); - it("mounts detect-browser without treating happy-dom as Safari", async () => { + it("renders nothing in other browsers", async () => { const { root } = await mountView(readDemo(import.meta.url, "safari")); - const el = root.querySelector("detect-browser")!; - expect(el.getAttribute("browser-name")).not.toBe("safari"); + const include = root.querySelector("include-content")!; + expect(root.querySelector("detect-browser")!.getAttribute("browser-name")).not.toBe( + "safari", + ); + expect(include.hasAttribute("is-active")).toBe(false); + expect(include.textContent?.trim()).toBe(""); }); }); diff --git a/packages/docs-site/public/demo-utils.css b/packages/docs-site/public/demo-utils.css index d77ee4c..9ee0f2f 100644 --- a/packages/docs-site/public/demo-utils.css +++ b/packages/docs-site/public/demo-utils.css @@ -206,8 +206,7 @@ } [id^="demo-provider-fetch-"] > :first-child, -[id^="demo-provider-geolocation-"] > :first-child, -[id^="demo-provider-localstorage-"] > :first-child { +[id^="demo-provider-geolocation-"] > :first-child { output { display: block; white-space: pre-wrap; @@ -217,9 +216,6 @@ } #demo-provider-orientation-request > :first-child { - #orient:not([is-success]):not([is-error]) ~ output { - display: none; - } #orient[is-success] ~ .status, #orient[is-error] ~ .status { display: none; diff --git a/packages/docs-site/public/demo-utils.ts b/packages/docs-site/public/demo-utils.ts index 2d95966..733a57f 100644 --- a/packages/docs-site/public/demo-utils.ts +++ b/packages/docs-site/public/demo-utils.ts @@ -1,94 +1,47 @@ -import { toJsonSafe } from "@excom/kit-utils"; -/** Demo helpers for docs-site live demos (`@use "/demo-utils"`). */ - -/** Write `JSON.stringify(event.detail)` into the nearest / current `output`. */ -export const _setOutputFromDetail = - ({ shouldAppend = false }) => - (e: CustomEvent) => { - const el = e.currentTarget as Element; - const output = ( - el instanceof HTMLOutputElement ? el : el.querySelector("output") - ) as HTMLOutputElement | null; - if (!output) return; - const line = JSON.stringify(toJsonSafe(e.detail ?? {}), null, 2); - if (shouldAppend) { - output.textContent += "\n" + line; - } else { - output.textContent = line; - } - }; - -export const setOutputFromDetail = _setOutputFromDetail({ - shouldAppend: false, -}); -export const appendOutputFromDetail = _setOutputFromDetail({ - shouldAppend: true, -}); - /** - * Write `JSON.stringify(event.target.provision)` into the nearest / current - * `output`. Neutron elements with a `provision` prop dispatch the - * framework-level `neutron-provision` event whenever it's set, so this - * works for any `provider-*` element without a bespoke event name. + * Demo helpers for docs-site live demos (`@use "/demo-utils"`). Each + * function is a `handle:` listener for work Quark has no declaration for. */ -export const setOutputFromElementData = (e: Event) => { - const scope = e.currentTarget as Element; - const output = ( - scope instanceof HTMLOutputElement ? scope : scope.querySelector("output") - ) as HTMLOutputElement | null; - const source = e.target as unknown as { provision?: unknown } | null; - if (!output || !source) return; - output.textContent = JSON.stringify(source.provision ?? null, null, 2); -}; + +/** `localStorage` key the `provider-storage` demo reads. */ +export const DEMO_STORAGE_KEY = "demo-provider-storage"; /** - * Bump a `$count` binding from JS (`quark` js-api demo). Receives the - * owner element from the sheet (`@on click (handle: incrementFromJs(closest(…)))`) - * and returns the click handler; `element.quark.setProperty()` re-runs - * every rule reading `$count` below the owner. + * Stands in for app code writing storage (`provider-storage` demo): stores + * the time, then re-sets `key-name` on every provider of that key. A + * same-tab write fires no `storage` event, so the re-set forces the read. */ -export const incrementFromJs = (owner: Element) => () => { - const current = Number(owner.quark.getPropertyValue("$count") ?? 0); - owner.quark.setProperty("$count", current + 1); -}; - -/** Static list for the `quark` iterate demo. */ -export const getPlanets = () => ["Mercury", "Venus", "Earth", "Mars"]; - -/** Note an intercepted event in the demo's `output` (`quark` events demo). */ -export const noteEvent = (e: Event) => { - const output = (e.currentTarget as Element).parentElement?.querySelector( - "output" +export const seedDemoStorage = () => { + localStorage.setItem( + DEMO_STORAGE_KEY, + JSON.stringify({ seededAt: new Date().toLocaleTimeString() }) ); - if (output) output.textContent = `"${e.type}" handled — navigation prevented`; + document + .querySelectorAll( + `provider-storage[key-name="${DEMO_STORAGE_KEY}"]` + ) + .forEach((provider) => { + provider.keyName = ""; + provider.keyName = DEMO_STORAGE_KEY; + }); }; -/** Noop stand-in for a real Safari polyfill loader (`detect-browser` demo). */ -export const loadPolyfills = (browserInfo) => { - console.log(" polyfill demo data:", browserInfo); - // load polyfills here - return "polyfills loaded"; -}; +/** Feature flags the app already holds (`quark` js-api demo). */ +export const DEMO_FLAGS = { "new-checkout": true, "gift-cards": false }; -/** `localStorage` key seeded by the `provider-storage` demo. */ -export const DEMO_STORAGE_KEY = "demo-provider-storage"; +/** + * Stands in for app JS handing its flags to the document (`quark` js-api + * demo): writes `$app-flags` on the listening element. + */ +export const handFlagsOver = (event: Event) => + (event.currentTarget as Element).quark.setProperty("$app-flags", DEMO_FLAGS); /** - * Writes a fresh payload into `localStorage`, then forces the nearest - * `` to re-read it. The browser's `storage` event only - * fires in *other* tabs, so a same-tab write needs `key-name` re-set. - * It is toggled off and back on around the write. + * Runs a renderable element's render / unrender thunk (`event.detail`) + * inside a view transition (`renderable-element` render-event demo). */ -export const seedDemoStorage = (e: Event) => { - const scope = e.currentTarget as Element; - const el = scope.querySelector("provider-storage") as - | (Element & { keyName: string }) - | null; - if (!el) return; - localStorage.setItem( - DEMO_STORAGE_KEY, - JSON.stringify({ seededAt: new Date().toLocaleTimeString() }) - ); - el.keyName = ""; - el.keyName = DEMO_STORAGE_KEY; +export const renderInTransition = (event: CustomEvent<() => unknown>) => { + if (!document.startViewTransition) return; + event.preventDefault(); + document.startViewTransition(() => event.detail()); }; diff --git a/packages/docs-site/support/docs/ADAPTER_STATE_ORCHESTRATOR.md b/packages/docs-site/support/docs/ADAPTER_STATE_ORCHESTRATOR.md index 81d5012..478954e 100644 --- a/packages/docs-site/support/docs/ADAPTER_STATE_ORCHESTRATOR.md +++ b/packages/docs-site/support/docs/ADAPTER_STATE_ORCHESTRATOR.md @@ -87,6 +87,8 @@ Initial State is usually authored statically, but need not be. The pattern does **A deliberately narrow language.** Expressions may compute and read, they may not cause effects. Method calls, mapped to native JS, are limited to a read-only allowlist. The Orchestrator declares relationships between facts... anything imperative must leave through a custom, named module function, defined by the author. The architectural rule — "the Orchestrator does not execute procedures" — is enforced at the language level. +**Not a bridge.** A module function is the exit for logic the language cannot yet express. Bridging a protocol (the network, storage, a sensor, a person) is not that kind of work: it belongs to an Adapter. + **Failure-inert.** A broken expression logs and no-ops, like CSS. Malfunction in the Orchestrator degrades the experience... it cannot corrupt the State. **Reactive and bounded.** It observes only what its own rules reference (the underlying MutationObserver is filtered to the attributes its rules name, plus element insertions — and removals only while a rule's match depends on children or sibling position), and its authority stops at an encapsulation boundary. @@ -99,11 +101,11 @@ This means: - Rules do not revert when their selector stops matching. Authors must write the inverse rule if they wish to un-apply the rule in question. - Specificity is not taken into account. If Rule A matches before Rule B, but has a higher CSS selector specificity, it will not matter. Rule B will be applied regardless. -Quark is also a derivative of CSS rather than a superset of it. Rules, selectors and declarations carry over; the at-rules do not. Quark has its own ten (`@use`, `@scope`, `@on`, `@dispatch`, `@command`, `@view-transition`, `@delay`, `@warn`, `@debug`, `@error`) and rejects every other one at parse time instead of ignoring it. +Quark is also a derivative of CSS, written in CSS syntax: rules, selectors, nesting and declarations carry over, and what Quark adds (variables, expressions, at-rules of its own) stays compatible with that syntax. What differs is the runtime: a stylesheet paints, a sheet writes State. Features of the browser's style engine, such as media queries and keyframes, stay in the stylesheet. ## How it differs -**Component frameworks** (React and similar frameworks) put a memory model in charge, run it through custom app logic, and target the document as output. The component conflates view, orchestration, and adapter (and sometimes even styling) in one imperative unit, which is why composition and reuse are difficult. A parent cannot reshape a child's logic without forking it. ASO separates these three roles into three languages — HTML, Quark, plain functions — and deletes the memory model. +**Component frameworks** (React and similar frameworks) put a memory model in charge, run it through custom app logic, and target the document as output. The component conflates view, orchestration, and adapter (and sometimes even styling) in one imperative unit, which is why composition and reuse are difficult. A parent cannot reshape a child's logic without forking it. ASO separates these three roles into three languages — HTML, Quark, plain functions — and deletes the memory model. Where an ASO app hands a region to such a framework, a shadow root is the boundary between them ([Handing rendering to a framework](/nucleus/packages/quark/use#md-handing-rendering-to-a-framework)). **MVC / MVVM** keeps a model separate from a view and spends its lifecycles synchronizing the two. ASO has one surface. There is nothing to bind. diff --git a/packages/docs-site/support/docs/BEST_PRACTICES.md b/packages/docs-site/support/docs/BEST_PRACTICES.md index e8a47d0..5d516c0 100644 --- a/packages/docs-site/support/docs/BEST_PRACTICES.md +++ b/packages/docs-site/support/docs/BEST_PRACTICES.md @@ -31,7 +31,7 @@ Short rules with rationale. They exist because the document *is* the state — k - **Little to none for simple apps.** Elements and Quark rules cover most needs; when they don't, that is usually a missing element or rule, not missing script. - **Built-in modules first.** `@use "quark:list"`, `quark:math`, `quark:string`, `quark:map`, `quark:date`, `quark:url`, `quark:util` cover the derivations views need (sorting, counting, plurals, clamping, dates, query strings); write a module function only for what they lack. -- **Keep functions pure** Side effects are permitted when unavoidable; in practice they are rarely needed because the document already holds what a side effect would manage. +- **Keep functions pure.** Values in, a value out. A module holds business logic; bridging the network, storage or the clock is an Adapter's job, and Quark does not await what a function returns. A function may build and return a node it owns without touching the document around it; imperative DOM work Quark cannot yet declare, such as moving focus, is a `handle:` listener. - **No build step required.** Serve files as-is. Add a bundler only when you have a reason. - **TypeScript is optional.** For a few dozen or hundred lines of pure functions, it usually isn't worth the build process. @@ -39,14 +39,14 @@ Short rules with rationale. They exist because the document *is* the state — k - **Use the proper listeners.** Use Quark `@on` or custom elements like `` to listen to events, since they have a safe teardown procedure. Avoid using raw JS, which does not. - **Invoke elements with commands.** A plain `