@gemboss/website-ui 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/js/website.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  // the export line and the // comments, plus auto-init): edit this file, then run the build. Keep comments on lines of
7
7
  // their own, so the build can leave them out.
8
8
 
9
- var version = '0.11.0';
9
+ var version = '0.13.0';
10
10
  var api = { version: version, init: init, destroy: destroy };
11
11
  // Page state. Set up by the first init(), never at import: a server can import this file.
12
12
  var doc, root, mine, fine, sys, wide, desk, mid, live = [], queued = false;
@@ -554,19 +554,46 @@ function theme(ctx) {
554
554
  relabel();
555
555
  }
556
556
 
557
- // Reveal at 15% visible, once. Failsafe: shown 2.5s after the init that found them, or at once without
558
- // IntersectionObserver or under reduced motion.
557
+ // Reveal (0.12.0, as gemboss.ai; system.json motion.reveal): once, when 12% of it is in view with the bottom 6% of the
558
+ // view taken off. Each .gw-reveal after the first among its .gw-reveal siblings waits 70ms more (at most 380ms), so a
559
+ // grid cascades. A [data-gw-count] number inside counts up from 0 when its reveal shows. Failsafe: all shown 1.6s after
560
+ // the init that found them, or at once without IntersectionObserver or under reduced motion (and then nothing counts).
561
+ var REVEAL = { threshold: 0.12, margin: '0px 0px -6% 0px', stagger: 70, staggerMax: 380, failsafe: 1600, count: 1100 };
562
+ // the number in the element's first words: "$1,200+" counts from 0 to 1,200 with its sign, grouping and decimals kept
563
+ function countUp(el) {
564
+ var n = el.firstChild, m = n && n.nodeType === 3 && /^(\s*\D*?)(\d[\d,]*(?:\.\d+)?)(.*)$/.exec(n.nodeValue), t0;
565
+ if (!m) return;
566
+ var num = m[2].replace(/,/g, ''), dec = (num.split('.')[1] || '').length;
567
+ function fmt(v) { v = v.toFixed(dec); return m[1] + (num !== m[2] ? Number(v).toLocaleString('en-US') : v) + m[3]; }
568
+ n.nodeValue = fmt(0);
569
+ requestAnimationFrame(function step(ts) {
570
+ var p = Math.min((ts - (t0 = t0 || ts)) / REVEAL.count, 1);
571
+ n.nodeValue = fmt(p < 1 ? num * (1 - Math.pow(1 - p, 3)) : +num);
572
+ if (p < 1) requestAnimationFrame(step);
573
+ });
574
+ }
559
575
  function reveal(ctx) {
560
- var els = [], io = null;
561
- each(ctx, '.gw-reveal', 'reveal', function (el) { els.push(el); return { stop: function () { if (io) io.unobserve(el); } }; });
562
- function showAll() { els.forEach(function (el) { el.classList.add('is-in'); }); if (io) io.disconnect(); }
576
+ var els = [], io, calm = still.matches;
577
+ each(ctx, '.gw-reveal', 'reveal', function (el) {
578
+ els.push(el);
579
+ return { stop: function () { if (io) io.unobserve(el); el.style.transitionDelay = ''; } };
580
+ });
581
+ function show(el) {
582
+ if (el.classList.contains('is-in')) return;
583
+ el.classList.add('is-in');
584
+ if (!calm) [].forEach.call(el.querySelectorAll('[data-gw-count]'), countUp);
585
+ }
563
586
  if (!els.length) return;
564
- if (!window.IntersectionObserver || mq('(prefers-reduced-motion: reduce)').matches) return showAll();
587
+ if (!window.IntersectionObserver || calm) return els.forEach(show);
588
+ els.forEach(function (el) {
589
+ var i = [].filter.call(el.parentNode.children, function (c) { return c.classList.contains('gw-reveal'); }).indexOf(el);
590
+ if (i > 0) el.style.transitionDelay = Math.min(i * REVEAL.stagger, REVEAL.staggerMax) + 'ms';
591
+ });
565
592
  io = new IntersectionObserver(function (list) {
566
- list.forEach(function (en) { if (en.isIntersecting) { en.target.classList.add('is-in'); io.unobserve(en.target); } });
567
- }, { threshold: 0.15 });
593
+ list.forEach(function (en) { if (en.isIntersecting) { show(en.target); io.unobserve(en.target); } });
594
+ }, { threshold: REVEAL.threshold, rootMargin: REVEAL.margin });
568
595
  els.forEach(function (el) { io.observe(el); });
569
- setTimeout(showAll, 2500);
596
+ setTimeout(function () { els.forEach(show); io.disconnect(); }, REVEAL.failsafe);
570
597
  }
571
598
 
572
599
  // Header: .is-scrolled past 8px, read once per frame (one scroll listener for every header).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gemboss/website-ui",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "The GemBoss website kit: tokens, header, footer, sections and the rules (AGENTS.md) that GemCommerce public websites share, plus a CLI that checks a site, reports kit gaps and upgrades.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
package/system.json CHANGED
@@ -78,6 +78,16 @@
78
78
  "dot": "rgba(255, 255, 255, 0.1)",
79
79
  "ink": "#ffffff"
80
80
  },
81
+ "announce": {
82
+ "$comment": "The announcement bar (0.12.0): one sentence over the header, as gemboss.ai draws it, the same in light and dark. bg its ground, ink its words, line its hairline at the bottom; tag and tag-ink the small pill before the sentence; link the link's words and link-line their underline. A brand sets its own bar here.",
83
+ "bg": "#171a38",
84
+ "ink": "#b9beda",
85
+ "line": "rgba(255, 255, 255, 0.06)",
86
+ "tag": "rgba(122, 132, 255, 0.2)",
87
+ "tag-ink": "#a7aeff",
88
+ "link": "#ffffff",
89
+ "link-line": "rgba(255, 255, 255, 0.28)"
90
+ },
81
91
  "roles": {
82
92
  "action": "The thing you click: ink, or the brand's accent when the site chose the accent action profile (profiles.action).",
83
93
  "accent": "Links, focus rings, active state, a chart line, the 8px label square; the primary button only through the accent action profile. Never body text, never words in a heading, never a section band.",
@@ -85,7 +95,8 @@
85
95
  "line-control": "The border of a form control (a field, a checkbox, a radio), 0.6.0: it must reach 3:1 on bg, bg-soft and surface, light and dark, where line and line-strong are hairlines that do not. Like the dashboard's --gemboss-field-line.",
86
96
  "wash": "The hero wash and illustrations only.",
87
97
  "scrim": "The veil over the page while a mega menu is open (0.11.0): a dim and a 4px blur, as gemboss.ai.",
88
- "stage": "The ground of a mega menu's pictures and nothing else (0.11.0)."
98
+ "stage": "The ground of a mega menu's pictures and nothing else (0.11.0).",
99
+ "announce": "The announcement bar over the header and nothing else (0.12.0)."
89
100
  }
90
101
  },
91
102
  "radius": {
@@ -113,13 +124,16 @@
113
124
  "wordmark": "clamp(40px, 9vw, 128px)",
114
125
  "$comment": "wordmark (0.7.0): the footer's last line on gemboss.ai, the brand's name, faint, at the end of the page. Decoration only, never a heading. Weight 800 (0.10.0, as gemboss.ai draws it): the one use of 800 in the kit.",
115
126
  "weight": 600,
116
- "tracking": "-0.03em"
127
+ "tracking": "-0.03em",
128
+ "h1_leading": 1.1,
129
+ "$comment_leading": "1.1 and 1.06 (0.12.0, gemboss.ai's): at 1.02 the descenders of a first line touched the capitals of the second (Chris, 15/10/2026).",
130
+ "h2_leading": 1.06
117
131
  },
118
132
  "heading": { "weight": 650, "tracking": "-0.022em", "line_height": 1.08 },
119
133
  "subhead": { "weight": 600 },
120
134
  "body": { "size": 18, "line_height": 1.6 },
121
- "label": { "size": 13, "weight": 500, "case": "sentence", "$comment": "muted, led by the 8px accent square; never uppercase tracking" },
122
- "button": { "size": 14, "weight": 600, "case": "sentence" }
135
+ "label": { "size": 13, "weight": 500, "case": "sentence", "$comment": "muted, led by the 8px square in ink (0.12.0, as gemboss.ai; the input --gw-label-mark paints it another colour); never uppercase tracking" },
136
+ "button": { "size": 14, "weight": 600, "case": "sentence", "family": "Inter", "$comment": "family (0.12.0): a button names the brand face alone, as gemboss.ai does. A glyph the face does not carry (the arrow → is not in Inter's web subset) is then drawn by the system's own fallback, the width gemboss.ai's buttons have (with the full stack it is a different face, 1px narrower, and a pair of buttons fits where gemboss.ai wraps it)." }
123
137
  },
124
138
  "space": {
125
139
  "scale": [0, 2, 4, 6, 8, 12, 16, 20, 24, 32, 40, 48, 64, 80, 96, 120],
@@ -168,6 +182,16 @@
168
182
  "properties": ["transform", "opacity"],
169
183
  "ease": "cubic-bezier(.22,1,.36,1)",
170
184
  "reveal_ms": 700,
185
+ "reveal": {
186
+ "$comment": "Scroll reveal (0.12.0, as gemboss.ai): a .gw-reveal rises distance px and fades in over reveal_ms when threshold of it is in view, the bottom margin of the view taken off; each one after the first among its .gw-reveal siblings waits stagger_ms more (at most stagger_max_ms); all are shown failsafe_ms after the script starts. A [data-gw-count] number inside counts up from 0 over count_ms when its reveal shows. The script's numbers are held equal to these by the tests.",
187
+ "distance": 20,
188
+ "threshold": 0.12,
189
+ "margin": "0px 0px -6% 0px",
190
+ "stagger_ms": 70,
191
+ "stagger_max_ms": 380,
192
+ "failsafe_ms": 1600,
193
+ "count_ms": 1100
194
+ },
171
195
  "spin_ms": 700,
172
196
  "$comment": "Animate transform and opacity only. Everything is still under prefers-reduced-motion (a busy button's ring turns once every spin_ms, as the dashboard's spinner, and stands still as an ellipsis). One exception, the spotlight (spot): its light is one painted layer of one element, so its radius may grow.",
173
197
  "spot": {
@@ -11,7 +11,8 @@
11
11
  the panel's (--gw-mega-w, --gw-mega-pv; 1000 and 320 when it says nothing), its columns too (--gw-mega-cols, 3).
12
12
  Limits: at most five top-level items, one .gw-btn (the primary action), one second text action (.gw-header__link).
13
13
  Each panel: two or three columns of four or five rows, at most one card, three links in its strip. The announcement
14
- bar is optional: one sentence.
14
+ bar is optional: one sentence, one link, a small pill before it if you like (.gw-announce__tag, one word); its
15
+ colours are the brand's (system.json color.announce), the same in light and dark.
15
16
  Two options (0.8.0):
16
17
  - The mobile menu drills down (data-gw-menu="drill" on #gw-mobile-menu, as gemboss.ai's phone menu): the top level
17
18
  lists the sections, a tap on one shows its level alone with Back and its title, and the actions stay in a bar at
@@ -25,7 +26,7 @@
25
26
  never be translated. Put the word for "Language" in the page's language in data-gw-label-language.
26
27
  Replace every [bracketed] placeholder; delete what you do not use.
27
28
  -->
28
- <div class="gw-announce" role="region" aria-label="Announcement">[One sentence of news.] <a href="[link]">[Where the link goes]</a></div>
29
+ <div class="gw-announce" role="region" aria-label="Announcement"><span class="gw-announce__tag">[New]</span> [One sentence of news.] <a href="[link]">[Where the link goes]</a></div>
29
30
  <header class="gw-header" data-gw-header>
30
31
  <div class="gw-header__bar">
31
32
  <a class="gw-brand" href="/"><img class="gw-brand__mark" src="[/logo.svg]" alt="" width="34" height="34"><span class="gw-brand__name">[Brand]</span></a>
@@ -26,7 +26,7 @@
26
26
  <body class="gw-page">
27
27
  <a class="gw-skip" href="#main">Skip to content</a>
28
28
 
29
- <div class="gw-announce" role="region" aria-label="Announcement">[One sentence of news.] <a href="[link]">[Where the link goes]</a></div>
29
+ <div class="gw-announce" role="region" aria-label="Announcement"><span class="gw-announce__tag">[New]</span> [One sentence of news.] <a href="[link]">[Where the link goes]</a></div>
30
30
  <header class="gw-header" data-gw-header>
31
31
  <div class="gw-header__bar">
32
32
  <a class="gw-brand" href="/"><img class="gw-brand__mark" src="[/logo.svg]" alt="" width="34" height="34"><span class="gw-brand__name">[Brand]</span></a>
@@ -134,7 +134,7 @@
134
134
  <div class="gw-wrap">
135
135
  <div class="gw-footer__grid">
136
136
  <div class="gw-footer__brand">
137
- <a class="gw-brand" href="/"><img class="gw-brand__mark" src="[/logo.svg]" alt="" width="26" height="26"><span class="gw-brand__name">[Brand]</span></a>
137
+ <a class="gw-brand" href="/"><img class="gw-brand__mark" src="[/logo.svg]" alt="" width="34" height="34"><span class="gw-brand__name">[Brand]</span></a>
138
138
  <p>[One sentence on what the brand does for the reader.]</p>
139
139
  </div>
140
140
  <nav class="gw-footer__col" aria-label="[Column heading]">
@@ -11,6 +11,8 @@
11
11
  role="status" line ("Sent. We'll reply to name@example.com.").
12
12
  - Consent boxes start unchecked, say exactly what the reader gets, and are never required to get something else.
13
13
  - One field and its button (a newsletter, "email me the guide"): the .gw-form-inline row, see templates/footer.html.
14
+ As a page's first screen (a contact page, 0.13.0): the heading is the page's one <h1>, drawn at the section size
15
+ (<h1 class="gw-h2">), and the page needs no hero above it.
14
16
  Limits: three fields, one .gw-btn; no field that the team does not read.
15
17
  -->
16
18
  <section class="gw-section" aria-labelledby="[form-h]">
@@ -3,7 +3,8 @@
3
3
  Variants: .gw-hero--center (centred, for short promises), .gw-hero--compact (subpages: less height, h2-sized h1).
4
4
  Add .gw-band--night for a dark hero (it counts as one of the page's three night bands).
5
5
  Limits: a short label, a two-part headline (muted first sentence, ink second), at most two lines under it,
6
- one .gw-btn and one secondary action. The optional window holds a real product screen (img with alt, width, height)
6
+ one .gw-btn and one secondary action. Instead of the window, a photo: <figure class="gw-figure"> with its caption.
7
+ The optional window holds a real product screen (img with alt, width, height)
7
8
  or a drawn mock-up. Numbers in it that are not real get the caption "Illustrative data." (vi: "Dữ liệu minh hoạ.",
8
9
  zh: "示意数据。"); real ones get their source there. Never tuck the hero under the header.
9
10
  -->
@@ -2,7 +2,7 @@
2
2
  Split: one idea told in words beside a figure (a real screen, or a drawn mock-up of the product).
3
3
  .gw-split--reverse puts the figure on the left on wide screens; on phones the words always come first.
4
4
  Alternate --reverse between consecutive splits. Limits: label, h2, at most two lines, a check list of three to
5
- five items, one action (a link or one button). The figure is a .gw-window or a <figure> with alt text; numbers in
5
+ five items, one action (a link or one button). The figure is a .gw-window (a screen) or a <figure class="gw-figure"> (a photo, with alt text and a caption); numbers in
6
6
  a window that are not real get the caption "Illustrative data.", real ones their source.
7
7
  -->
8
8
  <section class="gw-section">
@@ -0,0 +1,23 @@
1
+ // Every static HTML page, served byte for byte from the built file and prerendered at build time, so each page is a
2
+ // file on the CDN. A more specific route (app/blog/[slug]/page.js…) wins over this catch-all. Unknown URL: 404.html, 404.
3
+ // Written by `gemboss-website init --site` (@gemboss/website-ui); the site owns it from here.
4
+ import { readFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { ROOT, pages } from "../../lib/pages.mjs";
7
+
8
+ export const dynamic = "force-static";
9
+ export const dynamicParams = true;
10
+
11
+ export function generateStaticParams() {
12
+ return [...pages().keys()].map((route) => ({ path: route === "/" ? [] : route.slice(1).split("/") }));
13
+ }
14
+
15
+ const HTML = { "content-type": "text/html; charset=utf-8" };
16
+
17
+ export async function GET(_req, { params }) {
18
+ const { path } = await params;
19
+ const route = "/" + (path || []).join("/");
20
+ const file = pages().get(route);
21
+ if (file) return new Response(await readFile(join(/*turbopackIgnore: true*/ ROOT, file)), { status: 200, headers: HTML });
22
+ return new Response(await readFile(join(/*turbopackIgnore: true*/ ROOT, "404.html")), { status: 404, headers: HTML });
23
+ }
@@ -0,0 +1,3 @@
1
+ /* Stopgaps for what @gemboss/website-ui does not have yet (AGENTS.md section 9). Each: classes prefixed kitgap-, the
2
+ kit's tokens and scales only, never a .gw-* override, and a comment naming its gap (gap #N, from
3
+ npx gemboss-website gap). npx gemboss-website upgrade says when the kit ships it: then delete the stopgap. */
@@ -0,0 +1,16 @@
1
+ /* The site's own CSS, linked after the kit (website.css). Two jobs only (AGENTS.md sections 7 and 8):
2
+ 1. the brand's choices, in ONE :root block: its accent family and wash, and if it has its own, its announcement bar
3
+ and Stage colours (docs/theming.md). Delete the lines the brand keeps from the kit; GemBoss sets none.
4
+ 2. what the kit cannot know: an illustration, a drawn mock-up of the product. Tokens and scales only, classes with
5
+ the site's own prefix, never .gw-*. Something reusable the kit lacks is a gap: kit-gaps.css. */
6
+ :root {
7
+ /* --gw-accent-light: #5661f5; the accent on light pages: links' marks, focus ring, label square, check marks */
8
+ /* --gw-accent-strong-light: #3a44de; accent text: 4.5:1 on --gw-bg and --gw-bg-soft */
9
+ /* --gw-accent-soft-light: #eef0fe; its tint */
10
+ /* --gw-on-accent-light: #ffffff; words on it (the button, under data-gw-action="accent") */
11
+ /* --gw-accent-dark: #7c84ff; the same four in dark mode and night bands */
12
+ /* --gw-accent-strong-dark: #a3a9ff; */
13
+ /* --gw-accent-soft-dark: #1f2140; */
14
+ /* --gw-on-accent-dark: #0d0d12; */
15
+ /* --gw-wash-pink: #ff6f5e; --gw-wash-violet: #7c8af7; --gw-wash-sky: #43b0ff; --gw-wash-mint: #1fcf9f; */
16
+ }
@@ -0,0 +1,4 @@
1
+ /node_modules/
2
+ /.next/
3
+ /public/
4
+ .DS_Store
@@ -0,0 +1,41 @@
1
+ // The static HTML pages this site serves, and at which URL: the Next route (app/[[...path]]/route.js) serves them, and
2
+ // `npx gemboss-website build` writes the header and footer into them. An allow-list, not "every .html": partials/ and
3
+ // node_modules/ are full of HTML that is not a page. A new folder of pages goes into DIRS on purpose, e.g. ["blog", true].
4
+ // Written by `gemboss-website init --site` (@gemboss/website-ui); the site owns it from here.
5
+ import { existsSync, readdirSync, statSync } from "node:fs";
6
+ import { join, relative, sep } from "node:path";
7
+
8
+ export const ROOT = process.cwd();
9
+ // [folder, recursive?]
10
+ const DIRS = [["", false]];
11
+
12
+ // turbopackIgnore: these reads only run at BUILD (prerendering every page); without it Next traces the whole project
13
+ // into the function. At runtime the function only needs 404.html (next.config.mjs).
14
+ function htmlIn(dir, deep) {
15
+ const abs = join(/*turbopackIgnore: true*/ ROOT, dir);
16
+ if (!existsSync(/*turbopackIgnore: true*/ abs)) return [];
17
+ const out = [];
18
+ for (const name of readdirSync(/*turbopackIgnore: true*/ abs)) {
19
+ const full = join(/*turbopackIgnore: true*/ abs, name);
20
+ if (statSync(/*turbopackIgnore: true*/ full).isDirectory()) {
21
+ if (deep) out.push(...htmlIn(join(dir, name), true));
22
+ } else if (name.endsWith(".html")) {
23
+ out.push(relative(ROOT, full).split(sep).join("/"));
24
+ }
25
+ }
26
+ return out;
27
+ }
28
+
29
+ /** file → clean URL, like Vercel's cleanUrls: index.html → /, pricing.html → /pricing. */
30
+ export function routeOf(file) {
31
+ const stem = file.slice(0, -".html".length);
32
+ if (stem === "index") return "/";
33
+ return "/" + stem.replace(/\/index$/, "");
34
+ }
35
+
36
+ /** URL → file for every served page (404.html is the error page, not a page). */
37
+ export function pages() {
38
+ const map = new Map();
39
+ for (const [dir, deep] of DIRS) for (const f of htmlIn(dir, deep)) if (f !== "404.html") map.set(routeOf(f), f);
40
+ return map;
41
+ }
@@ -0,0 +1,15 @@
1
+ // Clean URLs: /pricing.html and /index.html redirect to the clean URL, /pricing/ to /pricing.
2
+ // Written by `gemboss-website init --site` (@gemboss/website-ui); the site owns it from here.
3
+ /** @type {import('next').NextConfig} */
4
+ export default {
5
+ trailingSlash: false,
6
+ poweredByHeader: false,
7
+ async redirects() {
8
+ return [
9
+ { source: "/index.html", destination: "/", permanent: true },
10
+ { source: "/:path(.*)\\.html", destination: "/:path", permanent: true },
11
+ ];
12
+ },
13
+ // an unknown URL runs the route at request time and reads 404.html: ship that file with the function
14
+ outputFileTracingIncludes: { "/*": ["./404.html"] },
15
+ };
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ // Copy the site's static files (not its HTML pages) into public/ before `next build`: Next serves only public/ and the
3
+ // routes in app/. An ALLOW-LIST: only what is listed here becomes a public URL. Written by
4
+ // `gemboss-website init --site` (@gemboss/website-ui); the site owns it from here.
5
+ // node stage-public.mjs # rebuild public/ (npm run build runs it)
6
+ // node stage-public.mjs --check # CI: public/ has what it needs and nothing internal
7
+ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
8
+ import { join, relative, sep } from "node:path";
9
+
10
+ const ROOT = process.cwd();
11
+ const OUT = join(ROOT, "public");
12
+ const ROOT_FILES = ["favicon.svg", "robots.txt", "sitemap.xml"];
13
+ const DIRS = ["assets"];
14
+ const SKIP = (name) => name.endsWith(".html") || name.endsWith(".md") || name === ".DS_Store";
15
+
16
+ function walk(dir) {
17
+ const out = [];
18
+ for (const name of readdirSync(dir)) {
19
+ const full = join(dir, name);
20
+ if (statSync(full).isDirectory()) out.push(...walk(full));
21
+ else out.push(relative(OUT, full).split(sep).join("/"));
22
+ }
23
+ return out;
24
+ }
25
+
26
+ function stage() {
27
+ rmSync(OUT, { recursive: true, force: true });
28
+ mkdirSync(OUT, { recursive: true });
29
+ for (const f of ROOT_FILES) if (existsSync(join(ROOT, f))) cpSync(join(ROOT, f), join(OUT, f));
30
+ for (const d of DIRS) if (existsSync(join(ROOT, d))) cpSync(join(ROOT, d), join(OUT, d), { recursive: true, filter: (src) => !SKIP(src.split(sep).pop()) });
31
+ const files = walk(OUT);
32
+ console.log(`public/: ${files.length} files`);
33
+ return files;
34
+ }
35
+
36
+ function check() {
37
+ const files = stage();
38
+ const bad = [];
39
+ for (const need of ["favicon.svg", "assets/kit/website.css", "assets/kit/website.js", "assets/site.css"]) {
40
+ if (!files.includes(need)) bad.push(`missing public file: ${need}`);
41
+ }
42
+ for (const f of files) if (/\.(html|md|py|mjs|sh)$/.test(f) || /(^|\/)(package(-lock)?\.json|vercel\.json)$/.test(f)) bad.push(`internal file in public/: ${f}`);
43
+ for (const b of bad) console.error(" " + b);
44
+ console.log(bad.length ? `stage-public: RED, ${bad.length}` : "stage-public: green");
45
+ return bad.length ? 1 : 0;
46
+ }
47
+
48
+ if (process.argv.includes("--check")) process.exit(check());
49
+ stage();
@@ -0,0 +1,5 @@
1
+ {
2
+ "$schema": "https://openapi.vercel.sh/vercel.json",
3
+ "framework": "nextjs",
4
+ "buildCommand": "npm run build"
5
+ }