diff --git a/.github/workflows/check-family-nav.yaml b/.github/workflows/check-family-nav.yaml new file mode 100644 index 0000000..766b904 --- /dev/null +++ b/.github/workflows/check-family-nav.yaml @@ -0,0 +1,38 @@ +name: check-family-nav + +# The bar at the top of the page and the card strip at the end of it are three +# copies of the same markup, one per sample repository - abap2UI5/samples, +# abap2UI5/samples-controls and abap2UI5/samples-stack. Copies drift, and the +# way they drift is quiet: a subtitle reworded on one page only, a "you are +# here" marker left on whichever page was copied from, a sibling dropped from +# the footer, a link to an address that has since become a 404. None of that +# breaks a build, and all of it is visible to every reader. +# +# The check is offline on purpose. Diffing the three against each other needs +# the network, and then this repository goes red because github.com is having a +# morning. It checks the canonical wording it carries itself instead - the same +# strings in all three copies, so rewording one means editing three files. That +# is the point, not a cost. + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: check-family-nav-${{ github.ref }} + cancel-in-progress: true + +jobs: + check-family-nav: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + - run: node scripts/check-family-nav.mjs diff --git a/package.json b/package.json index 671da5d..b6579cf 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,14 @@ "check:abap2ui5": "abap2ui5lint", "fmt:chains": "abap2ui5lint --fix", "check:overview": "node scripts/check-overview.mjs", - "check": "npm run lint && npm run check:abap2ui5 && npm run check:overview && npm run check:keywords && npm run check:abapdoc && npm run check:samples-md && npm run check:app-rules && npm run check:prose && npm run check:web", + "check": "npm run lint && npm run check:abap2ui5 && npm run check:overview && npm run check:keywords && npm run check:abapdoc && npm run check:samples-md && npm run check:app-rules && npm run check:prose && npm run check:web && npm run check:family-nav", "check:keywords": "node scripts/check-keywords.mjs", "check:abapdoc": "node scripts/check-abapdoc.mjs", "samples:md": "node scripts/generate-samples-md.mjs", "check:samples-md": "node scripts/generate-samples-md.mjs --check", "check:app-rules": "node scripts/check-app-rules.mjs", "check:prose": "node scripts/check-prose-names.mjs", + "check:family-nav": "node scripts/check-family-nav.mjs", "web:index": "node scripts/generate-web-index.mjs", "check:web": "node scripts/generate-web-index.mjs --check" }, diff --git a/scripts/check-family-nav.mjs b/scripts/check-family-nav.mjs new file mode 100644 index 0000000..83a2eba --- /dev/null +++ b/scripts/check-family-nav.mjs @@ -0,0 +1,164 @@ +/* + * The family blocks are three copies of the same markup, one per sample + * repository, and copies drift. This is what stops them. + * + * It does NOT diff the blocks byte for byte against the other two - a check + * that needs the network to say whether this repository is correct fails for + * reasons that have nothing to do with the change under review. It checks the + * things a wrong copy actually gets wrong: a link that goes somewhere else, a + * verb or a subtitle reworded on one page only, the "you are here" marker left + * on whichever page was copied from, a sibling missing from the footer. + * + * The canonical wording lives below and is identical in all three copies, so + * rewording a subtitle means editing three files - which is the point. + * + * Only these three lines differ between the copies. + */ +const SELF = 'samples-stack'; +const PAGE_HTML = 'web/index.html'; +const PAGE_CSS = 'web/stack.css'; + +import { readFileSync } from 'node:fs'; + +/* --------------------------------------------------------------- canon */ + +const BASE = 'https://abap2ui5.github.io'; + +const PAGES = [ + { + key: 'samples', + url: `${BASE}/samples/`, + repo: 'abap2UI5/samples', + verb: 'Learn', + subtitle: 'start here, one idea at a time', + question: 'Where do I start?', + }, + { + key: 'samples-controls', + url: `${BASE}/samples-controls/`, + repo: 'abap2UI5/samples-controls', + verb: 'Controls', + subtitle: 'every UI5 control, searchable', + question: 'Which control does what?', + }, + { + key: 'samples-stack', + url: `${BASE}/samples-stack/`, + repo: 'abap2UI5/samples-stack', + verb: 'Stack', + subtitle: 'OData, RAP and your system', + question: 'Will my system run it?', + }, +]; + +const TOOLS = [`${BASE}/playground/`, `${BASE}/docs/`]; + +/* The catalogue moved to the root of its Pages site when the in-browser demo + * was dropped, so this address is a 404 and stayed in one footer for a while. + * Nothing may link to it again. */ +const RETIRED = `${BASE}/samples-controls/search/`; + +/* ------------------------------------------------------------- helpers */ + +const problems = []; +const fail = (msg) => problems.push(msg); + +/** The text between two markers, or null. */ +function between(text, name) { + const start = text.indexOf(`${name}:start`); + const end = text.indexOf(`${name}:end`); + if (start < 0 || end < 0 || end < start) return null; + return text.slice(start, end); +} + +/** Every href in source order. */ +const hrefs = (block) => [...block.matchAll(/href="([^"]+)"/g)].map((m) => m[1]); + +/** Which of the three pages an points at. */ +function currentPage(block) { + const marked = [...block.matchAll(/]*>/g)] + .map((m) => m[0]) + .filter((tag) => tag.includes('aria-current="page"')); + if (marked.length !== 1) return { count: marked.length, key: null }; + const href = /href="([^"]+)"/.exec(marked[0])?.[1]; + return { count: 1, key: PAGES.find((p) => p.url === href)?.key ?? href }; +} + +/* --------------------------------------------------------------- checks */ + +const html = readFileSync(PAGE_HTML, 'utf8'); +const css = readFileSync(PAGE_CSS, 'utf8'); + +if (!between(css, 'family-nav')) { + fail(`${PAGE_CSS}: the family-nav:start / family-nav:end styles are missing`); +} + +const nav = between(html, 'family-nav'); +const three = between(html, 'three-pages'); + +if (!nav) fail(`${PAGE_HTML}: no family-nav:start / family-nav:end block`); +if (!three) fail(`${PAGE_HTML}: no three-pages:start / three-pages:end block`); + +if (nav) { + const want = [...PAGES.map((p) => p.url), ...TOOLS]; + const got = hrefs(nav).filter((h) => h.startsWith(BASE)); + if (got.join(' ') !== want.join(' ')) { + fail(`the bar links to\n ${got.join('\n ')}\n but must link, in this order, to\n ${want.join('\n ')}`); + } + + for (const page of PAGES) { + if (!nav.includes(`${page.verb} ${page.subtitle}`)) { + fail(`the bar does not carry "${page.verb}" with its subtitle "${page.subtitle}" - reword it in all three repositories or in none`); + } + if (!nav.includes(`title="${page.repo}"`)) { + fail(`the bar does not name the repository ${page.repo} in a title attribute`); + } + } + + const here = currentPage(nav); + if (here.count !== 1) fail(`the bar carries ${here.count} aria-current="page" links; it must carry exactly one`); + else if (here.key !== SELF) fail(`the bar marks "${here.key}" as the current page, but this repository is ${SELF}`); +} + +if (three) { + const want = PAGES.map((p) => p.url); + const got = hrefs(three).filter((h) => h.startsWith(BASE)); + if (got.join(' ') !== want.join(' ')) { + fail(`the three-pages strip links to\n ${got.join('\n ')}\n but must link, in this order, to\n ${want.join('\n ')}`); + } + + for (const page of PAGES) { + for (const [what, text] of [['verb', page.verb], ['repository', page.repo], ['question', page.question]]) { + if (!three.includes(text)) { + fail(`the three-pages strip is missing the ${what} "${text}" - reword it in all three repositories or in none`); + } + } + } + + const here = currentPage(three); + if (here.count !== 1) fail(`the three-pages strip carries ${here.count} aria-current="page" cards; it must carry exactly one`); + else if (here.key !== SELF) fail(`the three-pages strip marks "${here.key}" as the current page, but this repository is ${SELF}`); +} + +/* The footer is not a shared block - each repository links its own catalogue - + * but both siblings have to be reachable from it. */ +const footer = html.slice(html.indexOf('
+Repository · The catalogue as a page · - abap2UI5 · + Learn · + Controls · + Playground · Documentation
diff --git a/web/stack.css b/web/stack.css index 268520a..cbf32a3 100644 --- a/web/stack.css +++ b/web/stack.css @@ -28,6 +28,14 @@ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; --radius: 10px; --wide: 64rem; + + /* The three measurements the shared family blocks at the end of this file + * read. They are the only thing about those blocks that is allowed to differ + * between the three sample repositories, because the three pages are built + * around containers of different widths. */ + --family-width: var(--wide); /* what .masthead and main centre in */ + --family-gutter: 0rem; /* those have no side padding of their own */ + --family-bleed: 1.25rem; /* has this much, and the bar undoes it */ } @media (prefers-color-scheme: dark) { @@ -418,7 +426,7 @@ mark { .empty { margin: 2rem 0; color: var(--ink-soft); } -.how, .packages, .ladder { +.how, .packages { margin: 3rem 0 0; padding-top: 1.4rem; border-top: 1px solid var(--line); @@ -426,21 +434,21 @@ mark { scroll-margin-top: 15rem; } -.how h2, .packages h2, .ladder h2 { +.how h2, .packages h2 { margin: 0 0 .7rem; font-size: 1.15rem; letter-spacing: -.01em; } -.how ol, .steps { +.how ol { max-width: 46rem; margin: 0; padding-left: 1.2rem; color: var(--ink-soft); font-size: .92rem; } -.how li, .steps li { margin: .45rem 0; } -.how b, .steps b { color: var(--ink); } +.how li { margin: .45rem 0; } +.how b { color: var(--ink); } #pkgcards { display: grid; @@ -502,3 +510,181 @@ footer p { margin: .4rem 0; } .facts > div { grid-template-columns: 1fr; gap: 0; } .facts dt { font-size: .74rem; text-transform: uppercase; } } + +/* ------------------------------------------------------------ family nav */ + +/* family-nav:start + * + * The bar that ties the three sample pages together, and the card strip at the + * end of the page that says why you would go next door. Both are IDENTICAL in + * abap2UI5/samples, abap2UI5/samples-controls and abap2UI5/samples-stack - + * change them in all three repositories or in none. `npm run check:family-nav` + * fails when a copy drifts. + * + * They read three tokens each page sets for itself in :root and nothing else + * of their own: --family-width (the container the rest of the page centres + * in), --family-gutter (that container's own side padding) and --family-bleed + * (the side padding on , which the bar breaks out of to reach the edge). + * Every colour comes from the palette above, so light and dark come free. + * + * The bar is deliberately NOT sticky: two of the three pages already stick + * their filter panel to the top, and a second sticky strip above it would eat + * a phone screen twice over. */ + +.family { + margin: 0 calc(var(--family-bleed) * -1); + padding: 0 var(--family-bleed); + background: var(--panel); + border-bottom: 1px solid var(--line); +} + +.family-inner { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: .1rem; + max-width: var(--family-width); + margin: 0 auto; + padding: .35rem var(--family-gutter); +} + +.family-brand { + margin-right: .7rem; + font: 700 .78rem/1 var(--mono); + letter-spacing: .04em; + color: var(--ink); + text-decoration: none; + white-space: nowrap; +} +.family-brand:hover { color: var(--accent); } + +/* The verb is what a reader recognises - "samples-controls" tells a newcomer + * nothing, "Controls / every UI5 control, searchable" tells them everything - + * so the repository name is in the tooltip and the footer, not here. */ +.family-page { + display: inline-flex; + align-items: baseline; + gap: .45em; + padding: .3em .65em; + border: 1px solid transparent; + border-radius: 99px; + font-size: .85rem; + text-decoration: none; +} +.family-page b { font-weight: 600; color: var(--ink); } +.family-page span { font-size: .8rem; color: var(--ink-faint); } +.family-page:hover { background: var(--line-soft); } + +/* The one thing this bar has to say without spending a word on it: which of + * the three you are reading. Drawn off aria-current, so the markup is the + * same file on all three pages and only that attribute moves. + * + * The current page is coloured with --accent and not --accent-ink: the two + * repositories next door use --accent-ink for text ON the accent fill, where + * it is white, and white on --accent-soft is nothing at all. */ +.family-page[aria-current="page"] { + background: var(--accent-soft); + border-color: var(--accent); +} +.family-page[aria-current="page"] b { color: var(--accent); } +.family-page[aria-current="page"] span { color: var(--ink-soft); } + +.family-spacer { flex: 1 1 auto; } + +/* The playground and the documentation are tools, not sibling pages: they sit + * apart from the three and carry the outward arrow. */ +.family-tool { + padding: .3em .5em; + font-size: .8rem; + color: var(--ink-soft); + text-decoration: none; + white-space: nowrap; +} +.family-tool:hover { color: var(--accent); } + +.family a:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; + border-radius: 99px; +} + +/* On a phone the three verbs and the two tools still fit once the subtitles + * go. Dropping the bar entirely would be dropping the way out. */ +@media (max-width: 47rem) { + .family-page span { display: none; } + .family-brand { margin-right: .35rem; } +} + +/* --------------------------------------------------------- three pages */ + +/* The bar at the top answers "where am I". This answers "why would I go + * anywhere else", and it sits at the end because that is where a reader who + * is done with this page arrives. */ + +.three { + max-width: var(--family-width); + margin: 3rem auto 0; + /* A rule above it, because on one of the three pages this lands directly + * under another prose section that has one, and without it the strip reads + * as that section's tail rather than as its own thing. */ + padding: 1.4rem var(--family-gutter) 0; + border-top: 1px solid var(--line); +} +.three > h2 { margin: 0 0 .3rem; font-size: 1.15rem; } +.three-lede { + max-width: 46rem; + margin: 0 0 1rem; + font-size: .9rem; + color: var(--ink-soft); +} + +.three-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: .7rem; +} + +.three-card { + display: block; + padding: .85rem .95rem; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 10px; + color: inherit; + text-decoration: none; +} +.three-card:hover { border-color: var(--accent); } +.three-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } + +.three-verb { + display: block; + margin-bottom: .15rem; + font: 600 .7rem/1 var(--mono); + letter-spacing: .08em; + text-transform: uppercase; + color: var(--accent); +} +.three-repo { display: block; font-size: .95rem; font-weight: 600; color: var(--ink); } +.three-q { + display: block; + margin: .3rem 0 .2rem; + font-size: .88rem; + font-style: italic; + color: var(--ink); +} +.three-what { display: block; font-size: .85rem; color: var(--ink-soft); } + +/* Same trick as the bar, same reason. */ +.three-here { display: none; } +.three-card[aria-current="page"] { + background: var(--accent-soft); + border-color: var(--accent); +} +.three-card[aria-current="page"] .three-here { + display: block; + margin-top: .5rem; + font: 600 .74rem/1 var(--mono); + color: var(--accent); +} + +/* family-nav:end */