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.
- 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'sappcontainer once installed. - Distribution is GitHub VCS + git tags only. No Packagist.
- Versioned: change behaviour → bump tag (
vX.Y.Z) → bumpspdotdev/scuttle-devin the host app's owncomposer.lock, commit + push (never run a barecomposer updatedirectly on the production server, that's invisible to the committed lock and gets silently reverted by the next unrelated deploy).
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.
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.xmlat 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}/docsrendered byproject-docs.blade.phpas a docs site with nav, content and on-this-page).work.blade.php(/services, the services index: the twelve deliverables;/workredirects 301),cases.blade.php(/case-studiesindex),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 publishedcms_articlesrows for sitescuttle(written over the sd-cms MCP server at plan.scuttle.dev/mcp/cms, reviewed in the admin panel) ahead of the built-in news inPosts::builtIn(); a row with the same slug replaces the built-in one;sellsbecomesproductsandsells. 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 topublic/sitemap.xml. - Visit counting (2026-10-31): with
SCUTTLE_ANALYTICS_KEYset (the site key from sd-analytics, which the host'sconfig/scuttle-dev.phpsets for production), the layout adds the sd-analytics snippet (/_sda/s.js, first party, no cookie, no browser storage), a no-JS pixel, aBook a call clickevent and, on the contact page after a send, aContact form sentevent. 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.phpis one full statement in both languages (English default, Dutch through the switch, keyspv_*), 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 bycase-study.blade.phpat/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 whenSCUTTLE_TURNSTILE_SITE_KEYandSCUTTLE_TURNSTILE_SECRETare set in the host's .env, mail toSCUTTLE_CONTACT_TO)./scuttle.vcfserves the vCard as text/vcard;public/qr/scuttle-vcard.svgis 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 astep(research, plan, build, review, deploy, ongoing; Build entries asub: general or ai) for the home page loop, seeSTEPS,SUBS,EXTRAandloop(); 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 ofsite.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 fromdata-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 nineEXTRAentries open/contact?about=<name>and the contact form lists them. Dutch:Catalog::nlLoopStrings()(keyslp_*andt_<slug>), the ring labels switch throughwindow.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.gCSS 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.cmdline 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::beforedraws the " · " between them, and under 600 px every part gets its own line; the terminal window (.shot) under 980 px hasgrid-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 (routekey) and renders through the sameproductview. Adding a product: add it here and topublic/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: rendersscuttle::siteand servesrobots.txt/sitemap.xmlstraight from the package'spublic/.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 frompartials/illus.blade.php); its Dutch strings are pushed into thenlstack.product.blade.phprenders every catalog product,pricing.blade.phpthe pricing page.case-studyandprivacyuse the.pagearticle 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#CF5A26light,#E3835Edark since v0.3.0; the comparison with indigo and grey isdesign/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 withdesign/make-logo.sh '<accent>' '<accent light>'(needs rsvg-convert and ImageMagick).public/portrait.jpg,public/og-image.png. Logo and theme drafts live indesign/drafts/.- The demo panel on the home page (
#demo) is interactive: the sidebar buttons insite.blade.php(data-k) select an entry of theDobject insite.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 thenlstack (Object.assign(window.SCUTTLE_NL.t, {...})); data-driven pages build the map from thenlarrays inCatalog(Catalog::nl($p)),Cases,ProjectsandPostsand emit it with@json. Key prefixes per page:pr_pricing,p_andax_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.jsswaps 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_translatablechecks 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 .ctahas 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 throughprefers-color-schemewhen 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.phpwrites every page as static HTML whenSCUTTLE_DUMP=<dir>is set (skipped otherwise), so the theme can be screenshotted without a PHP host.config/scuttle-dev.php:domainviaSCUTTLE_DOMAIN.public/: all site assets (images, qr/, legal/*.pdf, vCard, manifest, robots, sitemap) published to the host'spublic/vendor/scuttle..env.example: documents env vars this package reads (currently justSCUTTLE_DOMAIN); all have safe defaults, so it's optional, not required for install. Copy relevant lines into the host app's own.env.
layout.blade.phpbuilds 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@contextas a directive).og:typedefaults 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 withdesign/make-fonts.sh <unzipped release folder>; the full SIL OFL text sits next to them);site.cssstarts with the@font-facerules 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.
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/privacyrun 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 (everydata-ielement is replaced by its string; the parser reads quoted and unquoted keys, hyphens allowed), setslang="nl", a Dutch title and description (Dutch::meta(), titles of services inCatalog::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.jsremembers the choice and sends a visitor who chose Dutch from an English address to its twin. A new paired page needs: its path inDutch::paired(), a Dutch title and description inDutch::meta(),data-ion every visible sentence, and the teststest_dutch_twins_are_real_pages_with_hreflangandtest_every_page_is_translatablepass. 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 carrydata-ikeys (t_<slug>,cx*_<slug>fromCases::nlStrings()). - Search titles of the money pages say what the page is (
Catalog::SEO_TITLES, and atitlepluscrumbsection in the other views); the breadcrumb uses the shortcrumbname. - Social cards. One 1200x630 card per page in
public/og/<slug>.png(slug = address with/as-); the layout picks it throughDutch::ogImage()and falls back toog-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+jsonblock that holds text from data (titles, summaries, names, CMS articles) is written withjson_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@contextkey 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_resolvesrequests every internal link on every page type and checks the social image file exists.
- Contact form errors travel in both languages: after a failed post the controller flashes
contact_errors_enandcontact_errors_nl(same errors, same order, built by running the validator once per language), the view renders the English list withdata-i="ctp_err_<n>"and puts the Dutch twin in the page's Dutch map, so/nl/contactis 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
.btnis 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.
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.
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.
- 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.pyruns 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.pylists 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),--mutedis #616161 in light, text on a coloured button is white in light and near black in dark, every scrollablepreand table wrapper hastabindex="0", no<aside>inside<main>(callouts arerole="note", side navigation is anavwith a label), and the archive rows have no link inside asummary. - No cookie on content pages.
routes/web.phptakes 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.pydoes the same against the live site, and.github/workflows/live-links.ymlruns 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.comin the CSP the day its keys are set.
- Text sizes (
font-size,font,line-height) inpublic/site.cssare rem on a 16 px root, so the browser's text size setting scales the site;TextScalesTestfails on a px size. 13 px is0.8125rem, never rounded. - The language switch on pages without a Dutch twin also changes
aria-label,title, the NDA link andlang:Dutch::ATTRIBUTES(English value to Dutch) goes to the page aswindow.SCUTTLE_ATTR_NLand the Dutch twins apply it on the server; the NDA link carriesdata-href-nl.scripts/switch-check.pyproves it in a browser. A newaria-labelortitleneeds its Dutch text inDutch::ATTRIBUTES(LanguageSwitchTestfails 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_timeandarticle:author(ArticleMetaTest).scripts/smoke-urls.txtmay hold anhttps://address (the booking page). DutchReadingTestkeeps known calques and stale product names out of the Dutch strings.