Skip to content

Latest commit

 

History

History
77 lines (66 loc) · 22.9 KB

CLAUDE.md

File metadata and controls

77 lines (66 loc) · 22.9 KB

CLAUDE.md: scuttle-dev

What this is

A Laravel library package (not an app) serving the scuttle.dev single-page site (plus legal PDFs, vCard, QR codes, SEO files) inside a host app via host-based routing.

Constraints

  • Host has no PHP/Composer. Run package commands via a one-off Docker image (docker run --rm -v "$PWD":/app -w /app composer:2 ...) or the host app's app container once installed.
  • Distribution is GitHub VCS + git tags only. No Packagist.
  • Versioned: change behaviour → bump tag (vX.Y.Z) → bump spdotdev/scuttle-dev in the host app's own composer.lock, commit + push (never run a bare composer update directly on the production server, that's invisible to the committed lock and gets silently reverted by the next unrelated deploy).

Marketing rules

Read docs/marketing-rules.md before writing or changing any page text, product, case study or call to action. It is Stas's list of what never goes on the site (zeros, overconfidence, client traces, old scare facts) and what always does (open prices, straight answers, what a product stands for). Add his new rules there with the date.

Layout

  • src/ScuttleDevServiceProvider.php: auto-discovered; loads routes + views, publishes config/assets.
  • routes/web.php: Route::domain(config('scuttle-dev.domain')) group: /, /pricing, /legacy-health-check, /products/{slug}, /case-studies/phalcon-to-laravel, /privacy, plus /robots.txt + /sitemap.xml at root.
  • src/Posts.php (news articles, each written to sell one service, /news, /news/{slug}, 301 from /blog), src/Projects.php (open source and own products, /projects, /projects/{slug}, /projects/{slug}/docs rendered by project-docs.blade.php as a docs site with nav, content and on-this-page). work.blade.php (/services, the services index: the twelve deliverables; /work redirects 301), cases.blade.php (/case-studies index), company.blade.php (/company, careers live inside it, never in the menu). Services live at /services/{slug}; /products/{slug} redirects 301. The sectors block left the home page on 2026-10-30 (council round 2); GitHub issue #1 (project tags per sector) stays open for a later, evidenced version.
  • Articles from sd-cms: Posts::all() returns the published cms_articles rows for site scuttle (written over the sd-cms MCP server at plan.scuttle.dev/mcp/cms, reviewed in the admin panel) ahead of the built-in news in Posts::builtIn(); a row with the same slug replaces the built-in one; sells becomes products and sells. sd-cms is an optional sibling in the host, never a dependency: Posts::fromCms() checks the class and the table and returns nothing in the package tests. The sitemap route appends those articles to public/sitemap.xml.
  • Visit counting (2026-10-31): with SCUTTLE_ANALYTICS_KEY set (the site key from sd-analytics, which the host's config/scuttle-dev.php sets for production), the layout adds the sd-analytics snippet (/_sda/s.js, first party, no cookie, no browser storage), a no-JS pixel, a Book a call click event and, on the contact page after a send, a Contact form sent event. The privacy statement (EN and NL, pv_stats) describes the counting only when the key is set and keeps the old promise otherwise; both are in one change by design. Google Analytics (SCUTTLE_GA_ID, empty on the server) and its banner stay dormant.
  • privacy.blade.php is one full statement in both languages (English default, Dutch through the switch, keys pv_*), processors DigitalOcean, Google (mail, backups) and Anthropic (Claude for the lead tooling: summaries of public company information and first email drafts, reviewed before sending); Gemini is no longer named.
  • src/Cases.php: the case studies (one anonymized client case, three own products), rendered by case-study.blade.php at /case-studies/{slug} and in the home carousel. src/Http/Controllers/ContactController.php + src/Mail/ContactMessage.php, the contact form on /contact (POST /contact, throttle 6 a minute, honeypot plus a signed 3-second minimum, Cloudflare Turnstile when SCUTTLE_TURNSTILE_SITE_KEY and SCUTTLE_TURNSTILE_SECRET are set in the host's .env, mail to SCUTTLE_CONTACT_TO). /scuttle.vcf serves the vCard as text/vcard; public/qr/scuttle-vcard.svg is a QR code that carries the vCard itself (rebuild both with the script in the commit history of 2026-10-24 or by hand: vCard 3.0, CRLF, folded at 75 bytes, base64 photo).
  • src/Catalog.php: the 13 products in three groups (Assess, Build, Run): names, leads, prices, features, steps. One source for the products mega menu, the home cards and tiles, the pricing page, the product pages and the footer. Names since v0.7.30 (2026-10-31, proposal 14): AI Agents and Skills (was AI Integration), System Integration (was Integrations), Mobile App (was Android App; Android now, iOS on request), Security Test (was Security Assessment), Architecture Plan (was Architecture Review); slugs and addresses unchanged. Every product also carries a step (research, plan, build, review, deploy, ongoing; Build entries a sub: general or ai) for the home page loop, see STEPS, SUBS, EXTRA and loop(); the menu keeps the three groups. Rebuild the social cards after a rename (names are on them). Prices (2026-11-04 rework): four fixed-price plans, each 16 hours, EUR 1,440 excl. VAT: Health Plan (was Health Check, address /health-plan, the old /health-check and /legacy-health-check 301 there), Security Scan (was Security Test, slug security-assessment), Architecture Plan, Automation Scan (new, slug automation-scan); the Maintenance Plan (was Maintenance Retainer, slug maintenance-plan, /services/maintenance-retainer 301s) is monthly: 8 hours EUR 720, with three bigger sizes that are pricing cards only, no catalog product (Catalog::MONTHLY, working names Plus 12 h EUR 1,080, Pro 16 h EUR 1,440, Max 24 h EUR 2,160, their button opens /contact?about=<name>); everything else is quoted per step at EUR 90 per hour excl. VAT; the pricing page shows three blocks of four (by the hour, fixed price, monthly) and the Services menu has two plan sections (Fixed price plans, Monthly plans) next to the remaining group columns; the catalog holds 13 products. The home page shows the twelve once, since v0.7.31 (2026-10-31, proposal 14 layout B, Stas: "layout B") as the loop under "How I work" (partials/loop.blade.php, CSS .loop, the last block of site.js): five steps (Research, Plan, Build, Review, Deploy) on a ring with Ongoing as the hub in the middle, drawn as inline SVG by the script from data-steps; every step's panel is in the HTML with real links, one open at a time once the script runs (all of them without it); the loop walks by itself every 4.5 s until a step is clicked, the orange dot travels the ring unless reduced motion is on; Build opens in two groups (General, AI and automation); the nine EXTRA entries open /contact?about=<name> and the contact form lists them. Dutch: Catalog::nlLoopStrings() (keys lp_* and t_<slug>), the ring labels switch through window.SCUTTLE_LOOP.lang(). The illustrated tiles that showed the twelve before live on only on /services (the three list cards went on 2026-10-30, their .c.g CSS with them). Plan cards (pricing.blade.php, .plan): label, title, amount with a unit line under it ("per hour", "fixed, 12 hours"), text, rule, bullets, button; the bullets row takes the spare height so amounts line up across a row and every button is full width at the bottom; "excl. VAT" is said once per block in its header, not per card; one "Recommended" badge per block (Projects in steps, Health Plan, Maintenance Plan Plus), the test counts three. Every product carries its own closing call to action (cta). Phone rules for the hero (ported from splotnikov-dev on 2026-10-30, keep both sites the same): the .cmd line under the buttons holds its parts in <span>s inside the <b> (EN in the view, NL in the string map, HTML allowed there); a CSS ::before draws the " · " between them, and under 600 px every part gets its own line; the terminal window (.shot) under 980 px has grid-template-rows:auto minmax(0,1fr) so the chip row keeps its 35 px before the log has typed anything (without it the two auto rows share the 640 px and the chips stretch to 240 px); the log (.log) under 600 px is 12 px with wrapping allowed inside a line. The hero title keeps a space before its <br> (EN in the view, NL in the string map) because under 600 px the break is hidden and the browser balances the title into three lines; without the space the two halves glue together. The Health Check keeps its own address (route key) and renders through the same product view. Adding a product: add it here and to public/sitemap.xml; the tests check both. Also /mail/signature-logo.png, the small logo in the CRM mail signature (white circle, 96 px), routed so it needs no asset publish on the server.
  • src/Http/Controllers/SiteController.php: renders scuttle::site and serves robots.txt / sitemap.xml straight from the package's public/.
  • resources/views/layout.blade.php: shared shell (nav with the Products mega menu, EN/NL switch on every page, theme button, dark footer block, Dutch strings for the shell); every page extends it. resources/views/site.blade.php, the home page (light first, grey plates, Geist, logo 37 "layered squares", decrypt headline, belts, 12 tiles with the line illustrations from partials/illus.blade.php); its Dutch strings are pushed into the nl stack. product.blade.php renders every catalog product, pricing.blade.php the pricing page. case-study and privacy use the .page article styles. Every page is switchable to Dutch, see below.
  • public/site.css + public/site.js, the only stylesheet and script. Accent token --acc (Claude orange #CF5A26 light, #E3835E dark since v0.3.0; the comparison with indigo and grey is design/drafts/accent-options.png). public/logo.svg (wide mark), public/icon.svg (square mark, source of the favicons); regenerate every logo asset in a new colour with design/make-logo.sh '<accent>' '<accent light>' (needs rsvg-convert and ImageMagick). public/portrait.jpg, public/og-image.png. Logo and theme drafts live in design/drafts/.
  • The demo panel on the home page (#demo) is interactive: the sidebar buttons in site.blade.php (data-k) select an entry of the D object in site.js (automation runs with a typed log, report pages with two readable pages and the rest blurred as confidential, anonymized cases with numbers). Runs and reports are examples with made-up names; the cases are anonymized real work, the cost-saving one is a worked example with illustrative numbers and says so. Panel content is English only.
  • Every page is switchable to Dutch (since v0.6.0): the EN/NL switch sits in the bar on every page, every visible sentence carries data-i="<key>" and the page pushes its Dutch map into the nl stack (Object.assign(window.SCUTTLE_NL.t, {...})); data-driven pages build the map from the nl arrays in Catalog (Catalog::nl($p)), Cases, Projects and Posts and emit it with @json. Key prefixes per page: pr_ pricing, p_ and ax_ product pages, wk_ work, cp_ company, ctp_ contact, csi_/csp_ case studies, pj_/pjp_/pjd_ projects, bl_/blp_ news; the shell and the home page keep their short keys. site.js swaps innerHTML, so an element with an svg or a route() href gets a <span data-i> around its text, and inline links inside a Dutch string use plain paths. News article bodies and project documentation stay English (the Dutch page says so in one line). test_every_page_is_translatable checks every page: switch present, a push block, at least 8 own keys, a Dutch string for every key. On a switch the right side of the bar stays put (nav.top .cta has a fixed min-width); the links keep one 22 px gap and re-centre, so they slide by a pixel or two (v0.7.7 dropped the per-link width reservation, which made the gaps uneven).
  • The Chamber of Commerce link is the company's own register page, https://www.kvk.nl/zoeken/#/96040947000061394009/ (the search URL with ?q= opened an empty search).
  • Theme: three-way control System / Light / Dark (.seg.th, System default; dark tokens apply through prefers-color-scheme when nothing is chosen and through [data-theme="dark"] when chosen).
  • Hero command line in site.blade.php (data-i="cmd_b", EN and the NL string) names the latest PHP security release (8.4.26 → 8.4.27, 21 Oct 2026, from php.net). Refresh it at every PHP security release; the test pins the current text.
  • tests/Feature/PreviewDumpTest.php writes every page as static HTML when SCUTTLE_DUMP=<dir> is set (skipped otherwise), so the theme can be screenshotted without a PHP host.
  • config/scuttle-dev.php: domain via SCUTTLE_DOMAIN.
  • public/: all site assets (images, qr/, legal/*.pdf, vCard, manifest, robots, sitemap) published to the host's public/vendor/scuttle.
  • .env.example: documents env vars this package reads (currently just SCUTTLE_DOMAIN); all have safe defaults, so it's optional, not required for install. Copy relevant lines into the host app's own .env.

Head, fonts and contrast (v0.7.12, 2026-10-30)

  • layout.blade.php builds the title and description for every page: title with the brand suffix when it fits in 62 characters, else the bare title cut at a word boundary under 70; description cut at a sentence end (not after "excl.") or a word boundary under 160. Every inner page also gets a BreadcrumbList; service pages a Service block, news articles an Article block ('@'.'context' in the views, because Blade reads a literal @context as a directive). og:type defaults to website, news articles say article. Test: test_search_snippets_fit_and_structured_data_is_valid.
  • Geist and Geist Mono are self-hosted in public/fonts/ (variable woff2, latin and latin-ext, from Geist v1.9.0 with the arrows U+2190 to U+2193 and U+2197 in the latin files; rebuild with design/make-fonts.sh <unzipped release folder>; the full SIL OFL text sits next to them); site.css starts with the @font-face rules and the layout preloads the two latin files. No request goes to Google Fonts any more.
  • Orange badges and the contact buttons use a deeper orange with white text in light mode and dark text on the lighter orange in dark mode (contrast over 4.5:1). The decrypt headline pins its box height while it settles so nothing below it jumps. Status of every page: docs/IMPROVEMENT-STATUS.md.

Round 2 (v0.7.17, 2026-10-30): Dutch twins, menu, home order, social cards

Decided by a small council (contrarian, first principles, outsider, executor; no expansionist, no peer round, chairman by the session; 3 of 4 for real Dutch URLs on the money pages, 4 of 4 to lead with the Health Check offer and to cut the menu).

  • Dutch twins. src/Dutch.php + Http/Middleware/DutchVersion.php: /nl, /nl/pricing, /nl/health-check, /nl/services, /nl/services/{slug}, /nl/case-studies, /nl/case-studies/{slug}, /nl/company, /nl/contact, /nl/privacy run the same controllers, then the middleware switches the rendered HTML to Dutch on the server with the strings the page already pushes for the JS switch (every data-i element is replaced by its string; the parser reads quoted and unquoted keys, hyphens allowed), sets lang="nl", a Dutch title and description (Dutch::meta(), titles of services in Catalog::SEO_TITLES_NL), canonical and og:url of the twin, og:locale, and rewrites links to paired pages to their /nl/ twin. Every paired page, English and Dutch, carries hreflang en, nl and x-default; the sitemap lists both twins with xhtml alternates. The EN/NL switch is two links on paired pages (.seg a) and two buttons elsewhere (news, projects, docs stay English-only with the in-place switch and no hreflang). site.js remembers the choice and sends a visitor who chose Dutch from an English address to its twin. A new paired page needs: its path in Dutch::paired(), a Dutch title and description in Dutch::meta(), data-i on every visible sentence, and the tests test_dutch_twins_are_real_pages_with_hreflang and test_every_page_is_translatable pass. Send Dutch outreach links to /nl/....
  • Menu and home. Menu: Services (mega menu, with an "All services" link to /services), Pricing, Case studies, News (added 2026-11-15 on Stas's word, once articles were published), Company, Contact, Book a call. Projects live in the footer; News is in the footer too. The home leads with the headline, one offer sentence (Health Check, two days, EUR 1,080 excl. VAT) and one button (book a call about it); order: hero, foundation block, case carousel, the loop, trust block, the live demo panel, the worked-with belt, closing call to action. The tile and case texts carry data-i keys (t_<slug>, cx*_<slug> from Cases::nlStrings()).
  • Search titles of the money pages say what the page is (Catalog::SEO_TITLES, and a title plus crumb section in the other views); the breadcrumb uses the short crumb name.
  • Social cards. One 1200x630 card per page in public/og/<slug>.png (slug = address with / as -); the layout picks it through Dutch::ogImage() and falls back to og-image.png. Rebuild: php design/og-data.php > /tmp/og.json && python3 design/make-og.py /tmp/og.json (Playwright). Rebuild after a new service, case, project or built-in news post.
  • JSON-LD rule (Stas's security fix 1816b1c, 2026-10-30). Every ld+json block that holds text from data (titles, summaries, names, CMS articles) is written with json_encode(..., JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) so a title can never close the script tag; a hand-written block holds fixed text only. The @context key is written '@'.'context' inside Blade. The tests parse every block on every page, English and Dutch (test_search_snippets_fit_and_structured_data_is_valid, test_dutch_twins_are_real_pages_with_hreflang).
  • test_every_internal_link_and_social_image_resolves requests every internal link on every page type and checks the social image file exists.

Round 10 (2026-10-31)

  • Contact form errors travel in both languages: after a failed post the controller flashes contact_errors_en and contact_errors_nl (same errors, same order, built by running the validator once per language), the view renders the English list with data-i="ctp_err_<n>" and puts the Dutch twin in the page's Dutch map, so /nl/contact is Dutch on the server and the EN/NL switch flips the errors. The referer only decides the language stored with the lead. Without the lists (an old session) the view falls back to $errors->all().
  • Phones: every .btn is at least 44 px tall under 600 px and long labels wrap (test_the_phone_rule_keeps_every_button_at_least_44_px_tall_and_lets_long_labels_wrap).
  • No long dash in any text file: test_no_tracked_text_file_holds_a_long_dash.

Cutover (done)

DigitalOcean provisioning, live deploy, and the DNS A-record cutover are done: scuttle.dev is served by the host app from this package (checked 2026-09-27, / returns 200 with vendor/scuttle/ assets). docs/cutover-runbook.md is the record of how it was done.

Deploy (set 2026-10-13, HARD RULE, user decision)

Production always runs the latest main of this project. When the user asks for a change and it is merged to main, deploy it yourself right away, without asking: push or merge, watch CI and the deploy to the end, and check the server runs that commit. If this project uses tags, deploy the latest tag every time. Anything not in main is local testing and is never deployed. A failing deploy is reported first as broken, with what it blocks. Never open PRs unless asked.

Round 4 (2026-10-30): accessibility in both themes, no cookie on content pages, link health

  • axe in both themes. Lighthouse runs in the browser's own colour scheme (dark on this laptop), so it missed light-theme contrast. scripts/axe-check.py runs axe-core (WCAG 2.2 AA plus best practice) at 390 px in light and dark, reduced motion on (the entrance animations otherwise give false contrast failures); scripts/overflow-check.py lists pages that scroll sideways at 360 px. Both found real defects that Lighthouse passed. Result after the fixes: 0 findings on every page of both sites in both themes. Run them after any CSS change. Fixes that matter for the next change: the light accent for text is a darker token (--acc #AD4719 in light, the lighter one stays in dark), --muted is #616161 in light, text on a coloured button is white in light and near black in dark, every scrollable pre and table wrapper has tabindex="0", no <aside> inside <main> (callouts are role="note", side navigation is a nav with a label), and the archive rows have no link inside a summary.
  • No cookie on content pages. routes/web.php takes the session, CSRF and queued-cookie middleware off every route except the contact page and its POST (withoutMiddleware, three CSRF class names for the Laravel versions). Test: test_content_pages_set_no_cookie_and_the_contact_form_keeps_its_session. The privacy statement says so in both languages (scuttle-dev). New pages with a form must be routed in the first block of the file.
  • Link health. The suite crawls every sitemap address (200, self-canonical, indexable), checks hreflang pairs are reciprocal and listed in the sitemap (test_sitemap_urls_resolve_and_hreflang_pairs_are_reciprocal) and every internal link (test_every_internal_link_*). scripts/check-live.py does the same against the live site, and .github/workflows/live-links.yml runs it every Monday.
  • Headers are set by nginx in the sd-admin repo, not by the package: CSP, HSTS (one year, no includeSubDomains or preload on purpose, other subdomains are not all verified), Referrer-Policy, Permissions-Policy, X-Content-Type-Options, X-Frame-Options and, since 2026-10-30, Cross-Origin-Opener-Policy same-origin. Turnstile (Cloudflare) would need frame-src https://challenges.cloudflare.com in the CSP the day its keys are set.

Round 15 (2026-11-01)

  • Text sizes (font-size, font, line-height) in public/site.css are rem on a 16 px root, so the browser's text size setting scales the site; TextScalesTest fails on a px size. 13 px is 0.8125rem, never rounded.
  • The language switch on pages without a Dutch twin also changes aria-label, title, the NDA link and lang: Dutch::ATTRIBUTES (English value to Dutch) goes to the page as window.SCUTTLE_ATTR_NL and the Dutch twins apply it on the server; the NDA link carries data-href-nl. scripts/switch-check.py proves it in a browser. A new aria-label or title needs its Dutch text in Dutch::ATTRIBUTES (LanguageSwitchTest fails otherwise).
  • Every image decodes async and loads lazily except the QR thumbnail on the contact page (ImageLoadingTest); the LCP element of every page is a heading or paragraph.
  • Articles carry article:published_time, article:modified_time and article:author (ArticleMetaTest). scripts/smoke-urls.txt may hold an https:// address (the booking page).
  • DutchReadingTest keeps known calques and stale product names out of the Dutch strings.