Skip to content

Redesign of msk-scripts.de - #61

Merged
Musiker15 merged 64 commits into
mainfrom
redesign
Oct 28, 2026
Merged

Musiker15 merged 64 commits into
mainfrom
redesign

Conversation

@Musiker15

@Musiker15 Musiker15 commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Draft while the redesign is in progress. Every phase lands here as one or more commits, CI runs on each push, and the whole thing goes live together when it is merged.

Phases

  • Phase 0: sample page, palette, type scale, German path words (decided outside the repo)
  • Phase 1: packages, foundation (tokens, fonts, Phosphor icons, base components). Visual check of all pages in a production build still open.
  • Phase 2: routing under /en and /de with a route table, redirects, hreflang, sitemap per language. Speaking product slugs follow in Phase 4.
  • Phase 3: header, navigation, account menu, footer, home page
  • Phase 4: FiveM catalogue and product page with variant choice
  • Phase 5: Discord bot pages, tools page, about page, order confirmation mail for yearly plans
  • Phase 6: cart, handover to Tebex, account page (legal wording pending a lawyer, kept in one table)
  • Phase 7: dashboard shell and shared components
  • Phase 8: Ticket Bot dashboard
  • Phase 9: Giveaway dashboard
  • Phase 10: admin dashboard (emails and IPs masked on the server, audited reveal per row)
  • Phase 11: cleanup (compat layer, DESIGN.md, docs, unused tokens and icons, more contrast and forced colours)

Ground rules

  • Anthracite ground, MSK green as the visible brand colour, every colour pair at WCAG 2.2 AA in both themes.
  • Fluid from phone to UWQHD: type, spacing and column grow with clamp(), components use container queries, running text stays at 65 characters.
  • No dependency that injects styles at runtime, the nonce CSP stays as it is.

components/icons/index.tsx is now the only place icons come from. It wraps
the Phosphor SSR build, so the same import works in server and client
components, and makes every icon decorative by default (aria-hidden, not
focusable) unless it gets an aria-label, as lucide did before. A test
covers both cases.

The 70 importing files only changed their import line; where the Phosphor
name differs the old local name stays as an alias, so no component body
changed. The image category icons keep their keys, the database values
stay valid.

Dependencies: lucide-react removed, @phosphor-icons/react 2.1.10,
@fontsource-variable/archivo 5.3.0 and @fontsource-variable/source-sans-3
5.3.1 added (the fonts are wired up in the next commit), postcss 8.5.32.
TypeScript stays on 6 (typescript-eslint allows <6.1.0), @types/node on 24
to match the server runtime.
@github-actions

Copy link
Copy Markdown

⚠️ Deprecation Warning: The deny-licenses option is deprecated for possible removal in the next major release. For more information, see issue 997.

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

OpenSSF Scorecard

PackageVersionScoreDetails
npm/@fontsource-variable/archivo 5.3.0 UnknownUnknown
npm/@fontsource-variable/source-sans-3 5.3.1 UnknownUnknown
npm/@phosphor-icons/react 2.1.10 UnknownUnknown
npm/postcss 8.5.32 🟢 7.4
Details
CheckScoreReason
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Security-Policy🟢 10security policy file detected
Packaging⚠️ -1packaging workflow not detected
Code-Review⚠️ 2Found 7/30 approved changesets -- score normalized to 2
Maintained🟢 1030 commit(s) and 11 issue activity found in the last 90 days -- score normalized to 10
Binary-Artifacts🟢 10no binaries found in the repo
Token-Permissions🟢 9detected GitHub workflow tokens with excessive permissions
Pinned-Dependencies🟢 10all dependencies are pinned
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
License🟢 10license file detected
Fuzzing🟢 10project is fuzzed
Signed-Releases⚠️ -1no releases found
Branch-Protection🟢 3branch protection is not maximal on development and all release branches
SAST⚠️ 1SAST tool is not run on all commits -- score normalized to 1

Scanned Files

  • package-lock.json

@github-code-quality

github-code-quality Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Code coverage overview

Languages: TypeScript

TypeScript / code-coverage/vitest

main 8ecdfd8 redesign da90694 +/-
62% 71% +9%
View coverage of the 10 most impacted files.
File main 8ecdfd8 redesign da90694 +/-
lib/tebex.ts 0% 70% +70%
lib/productView.ts 0% 74% +74%
lib/adminPayments.ts 0% 96% +96%
lib/routes.ts 0% 99% +99%
lib/products.ts 0% 100% +100%
lib/adminMask.ts 0% 100% +100%
lib/format.ts 0% 100% +100%
lib/i18n.ts 0% 100% +100%
lib/dashboardNav.ts 0% 100% +100%
lib/aboutFigures.ts 0% 100% +100%

ℹ️ Calculations represent line coverage. Results may change as calculations are updated.


Updated October 28, 2026 14:41 UTC

Tokens: neutral anthracite ground and surfaces instead of the GitHub dark
axis, MSK green kept as the brand colour, plus new tokens for the soft green
surface, the primary hover, the anthracite band and the green band. Form
controls get their own border token at 3:1 (WCAG 1.4.11). The log console
moves off #0d1117. The contrast test checks the twelve new pairs in both
themes; a counter-check with the old input border fails as it should.

Fonts: Archivo (weight and width axis) for headings, Source Sans 3 for
text, the system monospace for code. Inter and JetBrains Mono are removed.

Fluid layout: a fluid type scale (text-display, text-h2, text-h3,
text-product, text-lead, text-body, text-small, text-label) whose maximum is
at most 2.5 times its minimum so zoom still reaches 200 %. The page shell,
its gutter and the card grid minimum grow with clamp() instead of in steps.

The theme follows the system setting now instead of defaulting to dark.
Button: minimum heights instead of fixed ones (44 px for md and lg, 36 px
for sm), so long German labels wrap instead of being cut; hover uses the
new primary hover colour; focus uses the global 3 px outline; the outline
variant gets the 3:1 control border.

Input and the new Select share one field look: 3:1 border, card surface,
16 px text so iOS does not zoom, red border via aria-invalid.

Card: 10 px radius, a clickable card answers with a green border and a
shadow instead of jumping. Badge: new soft green "tag" variant.

Dialog: new modal on the native <dialog> element, which brings focus
trapping, Escape and an inert page without injecting runtime styles the
nonce CSP would block. A stylesheet rule stops the page behind it from
scrolling. @radix-ui/react-dialog was unused and is removed.

Radii follow the sample page: 6 px for controls, 10 px for cards.
The redesign moves every page under /en and /de and translates the German
paths (/de/rechtliches/agb instead of /de/terms). Links, the language
switcher, hreflang, the sitemap and the proxy all need the same answer to
"what is this page called in that language", so it lives in one table in
lib/routes.ts instead of being spread over string literals.

The module is pure on purpose: resolveRequest() holds the whole routing
policy (307 from / by cookie, Accept-Language, then English; 308 from any
other known form, including the old addresses, to the one canonical address
per language; exempt paths untouched), so vitest can check it without Next.
The legacy redirects from next.config.js are part of the table now.
The redesign gives every page exactly one address per language, /en/... and
/de/... with translated German paths, instead of English at the root and a
/de prefix that only the proxy knew about. This commit moves all pages under
app/[lang]/ with English folder names (the /en paths) and wires everything
that builds an address to the route table from the previous commit.

- proxy.ts asks resolveRequest() what to do: 307 from / (msk_lang cookie,
  then Accept-Language, then English), rewrite of the German form onto the
  folder, 308 from every other known form, old addresses included. The nonce
  CSP, the security headers and the body limits are unchanged; the page rate
  limits now match the internal path, so /en/tools/images and
  /de/web-tools/images share one bucket (before, /de/images was not limited
  at all).
- The proxy remembers the language of the last page in msk_lang (HttpOnly,
  Secure, Lax, one year), so / can send a returning visitor back to it and
  OAuth callbacks, Stripe and Tebex return URLs land in the right language.
- LocaleLink, LangProvider, the language switcher, canonical, hreflang
  (x-default now points at /, which picks the language), the sitemaps,
  robots.txt, llms.txt and JSON-LD all read the table.
- API routes, OAuth callbacks, /auth/discord, botproxy, transcripts and the
  root files stay without prefix. /auth/discord gets its own root layout,
  because it is now the only page tree outside app/[lang].
- A catch-all under [lang] keeps the 404 inside the shop layout.
- Mails link the new addresses in their language; old links in mails that
  were already sent keep working through the 308s.
- The old next.config.js redirects moved into the legacy table, so they
  reach the new address in one hop.
… texts

Under /en the legal texts are translations of the German originals, and the
pages now say so at the top, each in the wording of the legal draft of
27.10.2026 (section 1.3): terms with the German version prevailing plus the
consumer sentence, the DPA without it (it is only concluded with
businesses), privacy policy and withdrawal instructions only "also available
in German", because a precedence clause would not hold there (Art. 12 GDPR,
§ 356 (3) BGB), the imprint nothing. Each notice links the German page; the
German pages get a small link to the translation. The wording lives in
lib/legalNotice.ts with a test, so a precedence clause cannot slip onto the
wrong page.

The old "This is a translation for your convenience ... is authoritative"
lines in terms, DPA, privacy and withdrawal are gone: they repeated the
notice and, for privacy and withdrawal, contradicted it.

The absolute addresses in the texts point at the new paths
(/de/rechtliches/avv, /en/legal/dpa, ...). The privacy policy lists the new
msk_lang cookie and no longer claims that no cookie is set for the language.
The pages moved folders, so the AST graph pointed at files that no longer
exist. Regenerated with graphify update (no LLM).
The proxy wrote msk_lang on every page view under /en or /de. The language
is in the address, so that cookie was not needed, and the privacy policy
used to state that no cookie is set for the language. A cookie written
without the visitor's action is also hard to justify under § 25 TDDDG.

Now only the language switcher writes msk_lang, on click; the proxy still
reads it to choose where / leads. The unused `remember` flag is gone. The
privacy policy in both languages says when the cookie appears. A test
guards that the proxy never writes the cookie; re-adding the write turns
it red.
Decided on 27.10.2026: categories, compare, stats, images and upload get
German forms under /de (kategorien, vergleich, statistik, bilder,
hochladen). Dashboards, admin and the giveaway result link keep their
English sub-paths, the value of a dynamic segment never changes. The
English forms under /de still answer 308 to the German ones, the tests
expect the new words.
An unknown address under /en or /de answered with an empty error shell,
<html id="__next_error__"> without a lang attribute and without content.
Only the browser rendered the 404 page afterwards. That breaks WCAG 3.1.1
and leaves a crawler nothing to read.

The cause is Next itself: a thrown notFound() always ends in that shell,
whether the root layout is static or not (checked with a minimal app on
16.4.3). Only a page that renders normally gets server-side HTML.

So the proxy now decides. resolveRequest marks an address the route table
does not know as unknown, and the proxy rewrites it with the status 404 and
a request header the client cannot set itself. The catch-all renders the
404 page directly when that header is there and still throws notFound()
when it is not, so the status stays 404 in every case.

For a response that already carries the status 404 Next resolves the
metadata of the layouts only, so the layout names the 404 page and sets
noindex. Before, the page carried the home page title and "index, follow"
next to Next's own "noindex".

The 404 page itself is bilingual now. A notFound() thrown by a page (an
unknown package id) still gets the error shell; Next offers no way around
it short of the experimental global-not-found.
Phase 3 of the redesign, plan sections 5.3 and 5.10 and the sample page of
Phase 0.

Header: the 4 px MSK green stripe on top, the brand, then the three product
lines (FiveM scripts, Discord bots, web tools) and the about page, visible
instead of behind a "Bots" and a "Resources" menu. Each product line is a
link plus a small button that opens a panel with its pages and one line of
description, the disclosure pattern: Escape closes and returns the focus,
a press outside closes, hovering opens it with a mouse. The active line gets
a green underline. Pages that only arrive in Phase 5 (/discord, /tools,
/about, /fivem/free, /fivem/all-access) are linked already but not
prefetched, so they do not show up as 404s in the console.

Right side: language as a short code with a small panel, the cart with a
green count, and one account entry for both logins. The panel keeps the shop
login (CFX.re, through Tebex) and the bot dashboards (their own Discord
login) apart and holds the theme switch, as plan 5.3 asks: system, light,
dark as three toggle buttons instead of a menu. Below 1280 px a menu button
opens the product lines with their pages, account, language and appearance.
The sign-in return (?success, ?discordLinked) moved into a component of its
own, so the header no longer sits behind a Suspense boundary.

No search in the header (E5): the catalogue is far below the 20 products
where it pays off. SearchDialog, the old ThemeToggle, the Navbar re-export
and the unused footer stylesheet are gone.

Footer on the anthracite band: products, help (documentation, Discord
support, statistics), legal and contracts in four columns, the imprint one
click away, "Vertrag widerrufen" and "Verträge hier kündigen" unchanged. The
trust line keeps its legal wording; payment marks only show on shop pages.
Above the footer every public page gets the green closing band with the
Discord invite and the documentation as the second way. A test holds the
legal links to their routes in both languages.

Green kickers replace the mono eyebrows with a dash (exclusion list, 4.2);
.eyebrow stays as an alias for the pages of later phases. The browser bar
colour follows the new background tokens instead of the GitHub dark.
Plan section 5.4 and the sample page of Phase 0, English as the lead
version, checked with the longest German texts.

Hero on the anthracite band: one sentence on what MSK is, FiveM and
Discord in green, and the entry by need with a green line above it, one row
per product line with its price: the cheapest paid script from Tebex, the
cheapest ticket bot plan from lib/tiers.ts, free for the web tools. From
1280 px heading and entry list stand side by side.

Then, each section with a kicker and from 1600 px with its head beside the
content: at most three measured figures (the refund rate moves to the about
page, E6), the paid scripts with the subscription, both Discord bots in a
zigzag with price and the way to overview, dashboard and statistics, the
three web tools as a compact row, the free scripts as a list instead of big
cards, and three sentences on who is behind the shop, without a photo. The
green band above the footer closes the page, so the second closing section
is gone.

Removed, as the exclusion list (4.2) asks: the release log in terminal
look, "Why MSK" as a row of four icon cards, "How it works" and the old
call to action. lib/releases.ts stays, the updates move to the product
pages in Phase 4. Also gone: CustomPackageCard and CustomPackages, the
unused Divider, InfoSection and DiscordButton, content/home-features.ts and
their texts in lib/i18n.ts.

The catalogue cards only get the new type and the green framework tags;
the screenshot-first card is Phase 4. Prices on the home page are formatted
per language through Intl (19,99 € and €19.99, plan 5.9). The free scripts
carry a German description now. A test holds the "plans from" price to the
cheapest paid tier, so a price change cannot leave a stale one behind.
Header, footer and home page got new components and lost old ones
(SearchDialog, ThemeToggle, ReleaseFeed, WhyMSK and others), so the AST
graph pointed at files that no longer exist. Regenerated with graphify
update (no LLM).
Comment thread tests/footerLinks.test.ts Fixed
CodeQL flagged the replace(/<[^>]+>/g, '') in the footer link test as an
incomplete multi-character sanitisation. The test only compares the text
and renders nothing, but splitting at the tags reads the same text without
the pattern, so the alert goes away instead of being dismissed.
tailwind-merge only knows Tailwind's own font sizes and takes every other
text-* class for a colour. cn('text-[var(--color-primary-foreground)]',
'text-lead') therefore dropped the colour: the large primary button (size
lg, text-lead) lost its white label and showed the inherited dark text on
green. That is the cart button of the product page.

cn now extends the font-size group with the scale of globals.css (display
to label), so a scale size replaces another size and leaves the colour
alone. A test holds both halves.
…oice

Tebex keeps every script twice, as an Encrypted and a Source package with
two ids, two names and two pages. A buyer sees one script with a choice, so
the shop now groups the packages into products (lib/products.ts) and gives
each product one page at a speaking address: /en/fivem/garage,
/de/fivem/garage, the subscription at /en/fivem/all-access and
/de/fivem/alle-scripts.

Nothing is maintained twice: the pairing comes from the two package ids
content/resource-stats.ts already lists per paid resource, and from
SUBSCRIPTION_PACKAGE_IDS. The slug is the resource name without msk_, as
stable as a name gets. A package nothing configures still lands in exactly
one product, paired by its Tebex slug stem or on its own, so a script added
in Tebex keeps showing up without a deploy.

Addresses are decided before a page renders. Old id addresses
(/en/fivem/5732588, /packages/5732588, /package/...) answer 308 to the
product in one hop, with ?variant=source when the id was the Source
package; the canonical address never carries it. An unknown slug or id gets
the 404 page of its language with status 404 from the proxy. A notFound()
thrown by a page comes back from Next 16.4.3 as an empty error shell
without lang, and global-not-found only covers unmatched routes, so the
proxy marks the request and the product route renders the 404 content
itself, as the catch-all does since Phase 3. For a slug the configured
products do not know, the proxy asks Tebex, at most once a minute.

The product page (plan 5.6): screenshots first in the gallery and the
Tebex banner last, the buy box with Encrypted and Source as native radio
buttons, the price of the choice with VAT, the cart button of the old page
unchanged (same basket routes, Discord ID and gift dialogs, only the
package id follows the choice), the seller line naming Tebex Limited as
merchant of record, a comparison of the two releases, the requirements
from the documentation with a link to it (E4, no FAQ), and the last three
versions from the VERSIONS repository. On a phone the description moves
below the cart button, so name, choice, price and button fit the first
screen.

Sitemap, hreflang, canonical, JSON-LD (an AggregateOffer with both
variants) and /llms.txt name the product pages. The catalogue cards still
link to the ids and reach the products through the redirect; they follow
in the next commit.
The catalogue listed every Tebex package, so each script stood there twice
with the same text and the same banner. It now shows one card per product
(plan 5.5): the first screenshot after the Tebex banner, the name, one line
of benefit, the frameworks as soft green tags and the price "from" with
VAT. The banner is only used when Tebex has no other image. The card is
one link through the name; there is no cart button, the choice between
Encrypted and Source belongs on the product page.

The filter moved from a column beside the grid into one row above it. A
232 px column left two cards per row at 1440 px (945 px for the grid,
cards of 351 px); now the grid has the full width and shows three columns
at 1280 and 1440, four tracks at 2560 and five at 3440, and no card gets
narrower than --grid-card. The one facet left is the framework: with both
variants in every product a variant facet filters nothing, a price facet
over "from" prices next to nothing, and the "works with" names are the
requirements on the product page now. Several ticked frameworks mean any
of them.

The subscription is a band below the grid, said positively: every script
for a month, cancel monthly, a trial licence. The comparison of Encrypted
and Source closes the page, linked from the sentence above the grid.

The category pages and the home page use the same card. On a variant
category the card names that variant's price and opens the product page
with it chosen, so the Source category does not show the Encrypted price
as "from".

The per-package tables in lib/config.ts (badges, descriptions, tags) are
gone; what the shop says about a product is in PRODUCT_COPY, keyed by its
slug, in both languages.
The product model, the product page and the catalogue got new modules
(lib/products.ts, lib/productView.ts, components/products/) and lost old
ones (PackageCard, PackagesBrowser, the [id] route), so the AST graph
pointed at files that no longer exist. Regenerated with graphify update,
sequentially and without an LLM.
The garage runs on ESX, QBCore and Qbox, as its documentation says; config
and copy only named ESX. The scripts need msk_core 4.1.0 or newer, as the
5.6.1 changelog states, so the requirements block names that version.
Both confirmed by the owner on 28.10.2026.
The order confirmation is the contract confirmation under § 312f BGB, but
it only knew monthly plans. A yearly subscriber was told "49,00 € pro
Monat" and "verlängert sich monatlich", because formatSubscriptionPrice
returns the amount and nothing passed on price.recurring.interval.

The webhook now reads the interval off the subscription and both mails
take it as a required field, so no caller can fall back to "per month".
The confirmation prints price period and term in the words of § 6 (1) of
the terms and of the order summary in the dashboard: "pro Jahr" and
"zwölf Monate, verlängert sich jährlich" for a yearly plan. The trial
reminder had the same flaw in its last sentence and gets the same fix.

The English terms still said that subscriptions run monthly; § 6 (1)
now says one month or twelve months, as the binding German text does.

Tests cover both intervals in both languages, and fail when the builder
ignores the interval or the helper misreads it.
…button

Art. 246c Nr. 4 EGBGB requires the languages a contract can be concluded
in to be stated before the order. The ticket bot's order summary did not
name them. It now says "Vertragssprachen: Deutsch, Englisch." right above
the order button, as in draft 1.4 of the legal texts.

The price statement follows draft 2.3: the price line says only the
amount and the period, and a sentence below names MSK Scripts (Moritz
Kohm) as contract partner, Stripe as payment provider, the VAT exemption
under § 19 UStG and the price as final price. The old "Payment is handled
by Stripe" at the end of the terms line is part of that sentence now.

The wording lives in one table, subscriptionTermsTranslations, so the bot
landing page can use the same sentences under its price table. The
summary moved into its own component so a test can render it and check
that the contract languages and the price statement stand before the
button; moving either below it turns the test red.
The ticket bot and giveaway bot pages follow plan 5.7 now. The head sits
on the anthracite band with the price and the facts beside the heading,
then the page goes function by function. The rows of equal icon cards
are gone (exclusion list 4.2); items are grouped by spacing and a
hairline in a shared FactList, the giveaway commands are a real table.

The ticket bot plans are a table in the style of the sample page: plans
as columns, features as rows, the recommended plan marked, the trial
length at the price. Every value is read from lib/tiers.ts, the trial
from lib/stripe.ts. Under the table stands the price statement of draft
2.3 (contract partner, Stripe, § 19 UStG, final prices), from the same
strings as the order summary in the dashboard. A test changes a price
and a limit in TIER_CONFIG and expects the table to follow, so a number
typed into the component fails it.

The FAQ is gone from the ticket bot page (decision E4). Its answers moved
to where they belong: requirements and licence into a requirements
section with the docs link, cancelling under the table, "is switching
worth it" into a line that leads to the comparison page, which the page
did not link before. The hub text no longer says hosting comes with
Premium; it starts at Hosted.

Comparison and both statistics pages keep their content and get the
same head, lists and tables. Buttons and text links have band variants,
because the page green measures about 2.3:1 on anthracite.

No screenshots yet: the bot banners carry the old green mono eyebrow, so
the pages are laid out to read without an image until real Discord and
dashboard screenshots exist.
The navigation has linked to these four addresses since Phase 3, with
prefetching switched off because they did not exist. They do now, and
PENDING_PAGES is empty.

/discord shows both bots side by side: what each does, where its price
starts and the way to its page, dashboard and statistics. Description,
price and facts are the ones the home page and the bot pages already
use; the ticket bot's "from" price is the cheapest paid plan in
lib/tiers.ts, which a test checks by changing it.

/tools (German /de/web-tools) puts MSK Forms, Paste and Shortener one
after the other with their real screenshots, plus the image gallery.
Every address comes from content/custom-packages.ts, which now also
carries the documentation link of each entry (checked against the docs
sitemap) and a German description for the three tools. MSK Forms is the
one tool with paid plans; the page links to its own pricing page rather
than copying prices from another repository, and states contract partner
and VAT exemption as the bot pages do.

/about (German /de/ueber-msk) introduces Moritz Kohm, known as
Musiker15, without a photo: the facts of the imprint, self-employed
since May 2022, what he builds, measured figures with their sources and
the places to find him. The figures include the share of refunded or
charged-back payments (decision E6), read from the nightly Tebex
snapshot together with its counts; any figure whose source fails is
left out. A test covers that data path.

/fivem/free (German /de/fivem/kostenlos) lists the free scripts with
their frameworks, the live server count from fivestats.io, GitHub and
the documentation. The free script and tool ids now live once in the
content file instead of in each component.

All four pages are in the sitemap and have their own title and
description in both languages.
Since 19.09.2026 lib/tiers.ts gives bot hosting to Hosted and Business
only, and the comparison page's own FAQ already says so. Three of its
other passages still promised hosting "on any paid tier" in both
languages: the hosting row of the table, the case where our bot is the
wrong choice, and the answer on whether a server is needed. A Premium
customer reading those would expect something the plan does not
include. They now name the Hosted plan, as the FAQ and the landing page
do.
Phase 5 added the overview pages, the shared page head, fact list and
price table, the stats parts and the order summary of the dashboard, and
dropped the icon arrays of the bot pages. Regenerated with graphify
update, sequentially and without an LLM.
…tics

Terms: § 1 (3) still named German as the only contract language, which
contradicts the language clause in § 14 (3); it now points there. The same
change went to main directly.

Statistics pages: without data the hero showed "Not available" as the green
kicker, and green stands for success on this site. The kicker is left out
then; the warning line below says it in words. PageHero's kicker is
optional for that.
main carries the same terms § 1 (3) fix as this branch, with the old
addresses. The branch version is kept for both terms files because it has
the fix and the new /de/rechtliches and /en/legal addresses.
The admin had 13 tabs in one row without groups (plan 6.1). It now runs in
the shared app shell with the groups of plan 6.6: Overview, Sales,
Ticket Bot, Image CDN, Website, Administration. The contents of the areas
stay as they were; Phase 10 rebuilds them.

Who sees which entry does not change: adminNavSections() in
lib/adminTabs.ts is built on visibleTabs() and nothing else, so the sidebar
offers exactly the tabs ?tab= accepts, and empty groups are left out.
The addresses stay /admin?tab=<id>. A plain click switches in place as the
tab bar did (history.replaceState, no server round trip), a middle or
modifier click opens the address in a new browser tab. Session, permission
checks and adminRoute() are untouched.

The admin stays English only (E3), also under /de/admin, but every string
of the shell, the overview, the sign-in and the error boundary now comes
from adminTranslations in lib/i18n.ts, keyed by language, with adminCopy()
falling back to English. Tab labels moved out of ALL_TABS into the table;
the tab definitions gained their group. The error codes of the sign-in
stay keys into the table, never text from the address. The theme switch
gets English labels there instead of the German ones of the address.

Sign-in, error boundary and dashboard all render inside the shell, so the
admin has the one main landmark in every state.
The layout tree still showed every page directly under app/[lang] and the
root layout as the shop frame. It now names the (site) route group, the
addresses per server and area of the ticket bot, the (panel) group of the
giveaway dashboard, the admin in the shell and components/dashboard.
The route group, the app shell components and the new dashboard pages
and loaders, so graphify queries find them.
The ticket bot areas of Phase 8 need three things the shell of Phase 7 did
not have yet, and the giveaway and admin areas will need them as well:

- lib/format.ts formats dates, sizes, counts and retention periods through
  Intl, 27.10.2026 on German pages and Oct 27, 2026 on English ones. The
  dashboards showed three date formats side by side (plan 6.1). Dates are
  shown in German time, so the server renders the same day the browser
  hydrates; with the browser's zone a date near midnight would differ.
- Notice is the message inside a page: tone in sign, bar and title, never
  colour alone, with role status or alert for messages after an action.
- CopyField shows an address or redirect URI as selectable text with a copy
  button, and says so when the browser refuses the clipboard.

ConfirmDialog now hands the typed confirmation to onConfirm, because the
hosting removal route takes the typed server id as its own confirmation,
and shows an error inside the dialog so a failed action can be retried.
DataTable headers align in the middle, so sortable and plain headers line
up.
Each card of the ticket bot dashboard built its own fetch. Phase 8 rebuilds
all of them, and the API routes behind them do not change, so the requests
have to stay exactly what the routes read: method, address, headers and
body. A renamed key reaches a route as undefined and fails there, often in a
branch nobody clicks in a test run, such as removing hosting or a yearly
order.

ticketBotApi.ts now holds every request: checkout and customer portal, both
custom domain flows, bot control and logs, hosting set-up and removal, and
the transcript list. tests/ticketBotApi.test.ts pins each one against the
values the old components sent, copied from them rather than derived from
the module. A body that is not JSON (a 429 from proxy.ts, Next's HTML 500)
becomes an empty object instead of a parse error.
Phase 7 put the ticket bot dashboard into the app shell, one address per
server and area, but the areas still rendered the old tab contents. This
rebuilds them after plan 6.4, each as a component of its own:

- Overview: plan with trial or period end, transcripts stored and how long
  each is kept (from lib/tiers.ts), domain and hosting state as badges in
  word and sign. Below, only what needs a look: a running trial, a hosted
  bot that is down, a domain waiting for DNS. Then the subscription with
  the order summary of Phase 5, and server id, API key and documentation.
- Domains: both custom domains state their status once, put the DNS steps
  before the field, save with the same words ("Save domain") and remove
  only through a confirmation dialog. The upgrade line on a free plan now
  leads to the order summary; it used to start the Stripe checkout
  directly, without the summary § 312j (2) BGB puts before the order.
- Transcripts: a DataTable with sortable columns, sizes right-aligned,
  dates per language, paging below, separate empty states for a first use,
  a filter without results and an error, and the retention of the plan.
- Hosting: one main action that follows the bot's state (start a stopped
  bot, restart a running one), stopping as the second action behind a
  confirmation, the update with its warning, the address of the bot's own
  dashboard, the live log on the console tokens with pause, the
  credentials on FormField and FormSection with errors at their field, and
  removing hosting in a danger zone where the server id is typed into an
  empty field.

Dates go through lib/format.ts, prices through lib/tiers.ts, every request
through ticketBotApi.ts with the bodies the routes expect. Ownership is
untouched: the page still finds the server only among the session's own,
and every route checks it again.

DashboardClient, DashboardDomainCard, DnsInstructions, TranscriptsCard and
components/BotConfigEditor are gone; the last one was the only user of the
legacy msk-* classes. Translation keys they alone used are removed, the new
copy is in both languages.

tests/ticketBotAreas.test.ts renders the areas with fixture data: the tier
gates match lib/tiers.ts, each destructive action sends nothing until its
confirmation and then the expected body, and dates and prices follow the
page language.
The compatibility layer in globals.css kept the old msk-* utility classes
and the Tailwind 3 token aliases (bg-surface, border-borderlt, text-dim and
friends) alive for components that had not moved to the new tokens. Since
the hosted bot's control panel was rebuilt, nothing in app, components,
lib, content or tests uses a single one of them, so the layer goes now
instead of waiting for Phase 11.

docs/development.md said the layer existed for BotConfigEditor and named
Inter and JetBrains Mono as the fonts; both were out of date.
The ticket bot areas, the request module and the shared notice, copy field
and format helpers are new, five old components are gone, so graphify
queries find the current structure.
The giveaway areas of Phase 9 need three things the shell did not have
yet, and the admin of Phase 10 will need them as well:

- RowMenu is the overflow menu of a table row (plan 6.3: one action
  inline, the rest in a menu). It is a native popover, so the scrolling
  container of a DataTable cannot clip it, the browser closes it on
  Escape and on a click outside, and no script injects styles under the
  nonce CSP. It closes on scroll, so it never floats away from its row.
- SaveBar holds Save and Discard of a settings page. It appears only
  while there are unsaved changes and stays above the tab bar of a phone.
- lib/format.ts gains a date with its time of day, a relative time
  counted from a given moment, and a percentage, each per language.
  Giveaways end at a minute, and the old dashboard printed US dates with
  seconds on German pages too.

The DotsThree icon joins the icon list for the menu button. The scroll box
of DataTable is now positioned: text placed absolutely for screen readers
(an "Actions" header, "opens in a new tab") was laid out against the page
and widened it on a phone instead of scrolling inside the table.
The giveaway dashboard was one client file of 1,668 lines in which every
tab built its own fetch and its own request body. Phase 9 splits it into
one module per area, and the two API routes behind them do not change:
they pass the body on to the bot, so a renamed key arrives there as
missing and is refused, or silently ignored.

giveawayApi.ts now holds every request: reading each kind, create, edit,
pause, resume, end, cancel, extend, reroll, saving a giveaway as a
template, template save and delete, settings, and the four Tebex calls.
giveawayModel.ts holds the shapes the bot answers with and the rules that
turn a form into a body, moved out of the old file unchanged: prizes,
coupons, conditions, bonus clamping, and null versus empty for inherited
conditions.

Two rules are new and only act before a request: a form is checked by
the bot's own limits so a mistake shows at its field, and a duration is
a number with a unit (plan 6.5). A stored duration such as 23h59m goes
back unchanged while its fields are untouched.

tests/giveawayApi.test.ts pins each body against the values the old
components sent, written out rather than derived from the modules, and
checks that the Tebex status is only asked for by the server owner.
Phase 9 of the redesign (plan 6.5). The giveaway dashboard kept the old
tab contents in the new shell; the plan found six buttons per card with
a red Cancel as the only filled one, winners as raw ids next to US dates
with seconds, no overview, settings without structure, colour as a text
field and the first channel preselected when creating a giveaway.

- Overview: running, entries, next end and ended as tiles, below that
  what needs a look (paused giveaways, a recent one without a winner, a
  coupon without a Tebex key for the owner) and the running giveaways
  with time left and entries as bars. The bot keeps no history, so there
  is no chart over time.
- Giveaways: a table with a status filter, state in word and sign, one
  action inline and the rest in the row menu. Ending, cancelling and
  drawing again ask first with buttons that name the consequence. Each
  state offers only what the bot accepts in it, as the cards did.
- Each giveaway has a page with its facts, prizes and winners, and the
  edit form while it is open. Creating is a page of its own, in sections,
  with errors at the field and no preselected channel. A template can be
  passed in the address to start from it.
- Templates: a table and an editor page per template.
- Settings: sections with a save bar, colour as a colour field with hex,
  role lists as a searchable choice with chips, and a button preview.
- Tebex stays the owner's only; removing the key sits in a danger zone
  and asks first.

The old file is split into one module per area plus the provider, form
parts and a shared action hook. The provider stays in the layout, so
moving between areas and sub-pages does not load again. Requests, API
routes, session and permission checks are unchanged; the store area is
still rendered for the owner only and the page still sends anyone else
to the overview.

tests/giveawayAreas.test.ts renders every area with fixture data in both
languages and checks the owner gates, that destructive actions send only
from their confirmation with the old bodies, what each state offers, and
dates and numbers per language.
The giveaway dashboard is now one module per area with its request and
model modules, the old 1,668-line client file is gone, and the shell has
a row menu and a save bar, so graphify queries find the current structure.
Phase 10 of the redesign, plan 7 ("Datenschutz im Admin"). The admin sent
every buyer's email and every banned player's IP to the browser in full,
and the plan asks for them to be masked by default, with a reveal per row
that is recorded in the audit log (Art. 5 (1) c and Art. 25 GDPR). Masking
in the browser would not be masking: the full value would still sit in the
JSON, the network panel and any cache in between. So it happens before the
response leaves the server.

lib/adminMask.ts masks an email as m***@g***.com #3f9a and an IP as
192.168.x.x #3f9a (IPv6 2001:db8:x:x #3f9a). The tag is the start of an
HMAC of the whole value, keyed from SESSION_SECRET, so the same buyer keeps
the same tag across rows and a list of known addresses cannot be checked
against it offline. Fields named like an address are masked whatever they
hold; addresses inside free text (a ban reason, a note, an audit detail)
are found by pattern and checked with isIP, so times and dates stay.

Masked: payments, bans, player lookup, gift cards, coupons, uploads, the
image inventory and the audit log. API keys leave shortened as the table
always showed them, because plan 6.3 wants every look at a secret in the
admin on record too.

POST /api/admin/reveal hands out one field of one record: the email of a
payment, IP or email of a ban, the key of a ticket bot server. It runs
through adminRoute (session, member, Origin check, general limit), then a
limit of its own per admin, then kind, field and id against the allow-list
in lib/adminReveal.ts, then the permission of the area the record is shown
in. An unknown record answers exactly like a missing permission. The audit
entry (who, record, field, never the value) is written before the value is
sent; writeAudit now reports whether it succeeded, and without the entry
nothing is revealed.

Also for the rebuilt areas: GET /api/admin/overview computes the figures of
the overview (sales of the last 30 days from the latest 100 payments, new
API keys, pending uploads and images without a label) per permission, so
only counts leave the server. The audit log names the admin through a LEFT
JOIN on the team, and the packages carry product and variant from the
catalog grouping, so Encrypted and Source show as a pair.
Phase 10 rebuilds the thirteen admin areas, as Phases 8 and 9 did with the
bot dashboards. Each old tab built its own fetch and its own body, and the
routes behind them do not change: a renamed key would arrive as missing
and be refused, or be ignored without a word.

adminRequests.ts holds every write the areas send (give a package, refund,
coupons, gift cards, packages, bans, API keys, images, uploads, the news
popup, the team) plus the player lookup, the reveal of a masked value and
sign-out, and the addresses the areas read through useAdminResource. Each
body is the one the old tab sent, key for key. A failure becomes an Error
with the route's sentence, or the caller's when the body is no JSON (the
proxy answers 429 as text).

tests/adminRequests.test.ts pins every body against the old components,
written out rather than derived from the module.
Phase 10 of the redesign (plan 6.6). The admin kept the old tab contents
inside the new shell: an overview that listed the person's own
permissions, an audit log of Discord ids and raw JSON, payments without
filter or sum and a Refund button in every row, window.confirm and
window.prompt for what cannot be undone, and inline English everywhere.

- Overview: revenue, payments and refunds of the last 30 days and new API
  keys as tiles that lead to their area, a trend only when the latest 100
  payments reach back far enough to compare, and below them what needs a
  look (uploads waiting, images without a label, chargebacks), each only
  when it is not zero.
- Payments: a table with filters for paid, free, refunded and charged
  back, a period and the sum. A refund only in the detail of a paid
  payment, behind a confirmation that names the amount and asks for the
  transaction id. "Give package" is the page's one primary action.
- Audit log: sentences with the admin's name ("Musiker15 approved the
  upload opengewerk-logo"), filters for person, action and period, the
  recorded detail behind a disclosure.
- Team: permissions grouped like the sidebar, role templates that set the
  boxes, a save bar; removing asks first.
- Packages: Encrypted and Source of one script in one row. Bans: date,
  search, IP and email masked with a reveal. Lookup: a hint for a CFX.re
  name instead of "Notch". Coupons, gift cards, API keys, images, uploads
  and the news popup moved onto DataTable, RowMenu, FormField, SaveBar and
  ConfirmDialog; the preview of the popup follows the theme.
- Every action that cannot be undone sends only from its confirmation,
  one per row so the dialog knows its record from props. Errors stand at
  the field. Tables scroll in their own container, pages are 25 rows.
- Locked areas (plan 6.6, the Ticket Tool pattern): the sidebar lists every
  area, and the ones the person may not open stay as text with a lock and
  the permission they need. Their page shows that note and mounts nothing
  that loads; `?tab=` to such an area no longer falls back to Overview in
  silence. The routes refuse exactly as before.
- Masked values come from the server and get a Reveal button per row; the
  full value stays in the state of that row. API keys are copied after a
  reveal, which is recorded like every other.
- Every string is in adminTranslations, English only (E3) and ready for a
  second language; the permission labels moved there from lib/adminPerms.
  Dates and amounts go through lib/format, which gains formatMoney.

The area files replace the tab files one to one, plus adminUi.tsx for
what they share. tests/adminAreas.test.ts renders every area with the
JSON the real routes send for fixtures full of addresses and checks that
none reaches the markup, that reveals ask for the one field, that each
destructive action sends only from its confirmation with the old body, and
the content plan 6.6 asks for. tests/adminNavLocked.test.ts covers the
locked entries, their pages and the routes that still refuse.
Plan 6.6 moves the developer notes that stood above the admin tabs into
the documentation and leaves a help link in the admin. docs/admin-dashboard.md
has one section per area, reached from each area's "Admin guide" link, and
describes the masking: the format, where it applies, the reveal route with
its checks and its audit entry. features.md links to it.
- The payments table lost its Details column: the transaction id opens
  the detail now, which leaves the email and its reveal room at 1440 px.
- A reveal button is no longer disabled while it loads. A disabled button
  drops the focus, and the same button turns into Hide; outside a dense
  table it is 44 px, as in the payment detail.
- Role templates in the team editor no longer stretch to the height of
  the section heading, link buttons in tables sit flush with the text, and
  the filesystem check says it has not run instead of showing an empty box.
- The audit fixture is in the order the route sorts by.
The admin is now one module per area with a request module and shared
parts, the old tab files are gone, and masking, the reveal route and the
overview route are new, so graphify queries find the current structure.
After the redesign a number of design leftovers had no reader: the second
green (--color-accent and its label), the labels of status fills that no
component draws (success, warning, info), the Discord text colour, the
Premium+ tier colour, the eight ecosystem badge colours, the 3xl and 4xl
breakpoints, two slide animations, the hero gradient, container-prose and
the .eyebrow alias. Keeping them meant DESIGN.md and the contrast test
described colours the site no longer shows.

Badge is now what every call site already used it as, the soft green tag,
so its unused ecosystem variants go and the call sites lose the variant
prop. Card loses its five sub components, nothing imported them. The sale
price keeps its colour under the name --color-sale, since it is no badge.
The four remaining .eyebrow spans use .kicker, which had the same rule.

The contrast test is rewritten in English around token names: it now also
checks text on the tile, the hovered green links, the status colours on
ground, card, tile and their own tint, the Discord mark, the Business chart
label, the sale price and the focus ring on a card. A new coverage test
fails when a colour token has no pair, so a new token cannot ship untested.

The comments in globals.css are English now and describe the current
rules instead of their history.
The icon module is meant to be the bundle's allowlist, but 25 of its 116
icons had no import left after the rebuilt pages and dashboards, among
them Bell, ChartBar, Rocket, Shield, TerminalWindow and Wrench. They go,
and the module comment now says that an icon without an import is
removed.

The comments in the icon module, the GitHub mark and the gallery category
tile still explained themselves against lucide-react; they now describe
the Phosphor setup as it is.
The rebuilt pages left copy behind that nothing renders: 23 translation
lines in lib/i18n.ts (the giveaway overview counters, the verify step's
"signed in as", the resource rank, the image detail arrows, the order
cancelled kicker, an admin team field and two more), the featured
package ids of the old home page, the custom packages section title and
the CommandRow type of the old command table.

content/custom-packages.ts loses the fields only the old package cards
read: price, isFree, the button labels (the translations carry those
now), the tags and the colour variant of each badge, since every tag is
the same soft green. Its header explains the fields that are left and how
an entry reaches the page. The two landing copy headers no longer talk
about icons that are kept in step with the lists.
…ette

The readable view of sitemap.xml was the last place that still painted
the GitHub dark palette (#161a20, #1f242c, #e6edf3, #8b949e) and the old
dark green #60b02f. That palette is on the exclusion list of the
redesign. The stylesheet now uses the values of the dark theme tokens:
ground, card, text, muted text, hairline and green. Search engines read
the XML and never see this, so only people checking the sitemap by hand
notice the change.
DESIGN.md still described the workbench design: Inter and JetBrains
Mono, a GitHub-dark ground, two greens with a monospace label rule and
ecosystem coloured badges. None of that is in the code since the
redesign, so the file pointed anyone adding a page the wrong way.

It now covers the principles and the binding exclusion list, the colour
tokens per theme with their roles, the fixed places of MSK green and the
logo ramp, the contrast rule and what tests/contrast.test.ts checks, the
fluid type scale and fonts, the layout tokens and the breakpoints that
are actually used, the components of components/ui, components/page and
components/dashboard with when to use which, icons, motion,
accessibility, the bilingual rules, and how to add a colour or an icon.
It links the docs pages instead of repeating them and names the two
things plan 4.7 asks for that are not built yet (prefers-contrast and a
forced-colors pass).
docs/development.md still named Inter, JetBrains Mono and lucide-react,
gave #60b02f as the dark green, listed a Container component and a
BotConfigEditor.tsx that no longer exist, described the admin as one
*Tab.tsx per area, showed a NEWS_POPUP constant that moved into the
database, called the custom packages "Discord bots and GitHub repos" and
listed js-cookie, which nothing imports. The tree now has the page,
stats and account folders, the full dashboard kit and the docs that
exist, and the styling section names the anthracite ground, the contrast
test and the utility classes of globals.css. The test count is current.

docs/features.md gave the legal forms as unprefixed German addresses and
/report, and promised badges, a custom packages section and a release
feed that "moves in Phase 4"; docs/admin-dashboard.md pointed uploads at
/images/upload. Both now give the /en and /de addresses.

ADMIN_DASHBOARD_PLAN.md and PRICING_MIGRATION.md stay as written and get
one line that marks them as historical. The README links the admin guide
and says what DESIGN.md covers.
Card lost its sub components, Badge its variants, the icon module 25
icons and lib/config.ts the featured package ids, so graphify queries
should no longer find them.
Plan 4.7 asks the shop to follow the visitor's system settings; only
reduced motion was in the code. "More contrast" now raises muted text and
lines to full strength, and in Windows contrast themes controls keep a
system border and focus uses the system highlight. A test guards the three
rules; a weakened contrast value turns it red.

SECURITY.md pointed at the upload client's old path. js-cookie and its
types were in package.json although nothing imports them any more.
A browser that reaches the bot dashboard host without a valid proxy session
went to the fixed BOT_DASHBOARD_RETURN_URL. On the server that still names
/ticketbot/dashboard, which after the redesign only works through a 308 and
always lands in English. The address is now built from the route table in
the visitor's language, taken from Accept-Language because the shop's
msk_lang cookie does not reach the subdomain. The header can only choose
between /en and /de, never the origin or the path; a test covers both and
fails when the language is ignored. The env variable is no longer read.
@Musiker15
Musiker15 marked this pull request as ready for review October 28, 2026 14:43
@Musiker15
Musiker15 merged commit 3104ba6 into main Oct 28, 2026
11 checks passed
@strix-security

Copy link
Copy Markdown

Strix is installed on this repository, but we couldn't run this PR security review because this workspace's trial has ended. Add a card to resume code reviews here.

So far, Strix has reviewed 50 pull requests and surfaced 1 security issue across this workspace.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants