Repository navigation
fix(blog): point quotation marks the right way in every locale - #251
Merged
Merged
Conversation
… definition post (slot 2026-10-08) Post 1 of the positioning wave: the executable business ontology defined from the objectstack README and the Business Ontology concepts page, quoted rather than paraphrased — the six-kind core mapped onto the bundled CRM example, the four projections with a removal test to tell core from projection, the three things it is not, where code goes, and the four promises led by Executable. ObjectOS appears only as the canonical T4 + E1. Eight locales (zh-Hant generated by gen:zh-hant), one cover and two diagrams, in English text and in Chinese text for the zh-Hans/zh-Hant pair. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
The post was written for the Thursday 2026-10-08 slot and dated for it, but it was never merged that day, so the slot passed with the post still unpublished. Under the publishing-cadence rules in AGENTS.md, a post's `date` is its planned slot and merge time is publish time; the forward-only rule protects the dates of posts that are already live, and this one is not. The seat therefore moves it to the next free slot, today, Thursday 2026-10-15, and merges it today. Only the `date` frontmatter line changes, in the seven hand-maintained locales (en, zh-Hans, ja, de, es, fr, ko), bare-date format kept and no `updated` added; zh-Hant is regenerated from zh-Hans with `pnpm gen:zh-hant`. No other file changes. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
Certification, AI governance tiers, MCP setup, templates, Cloud data,
Business Self-Managed, and two locale overstatements, each re-sourced
from objectos and objectstack docs at origin/main:
1. Enterprise card: "SCIM + compliance (SOC 2 / ISO)" -> "SCIM, compliance
attestations" (license.mdx:72-73; security.mdx:142 says ObjectOS itself
is not certified).
2. AI page: governance claims written per edition (license.mdx:42-43):
a taste on Free, in-app AI on Team, AI action audit / guardrails /
permission scoping from Business. The "0 bypasses" tile, the Ask audit
item, the "never how it is governed" item, the edition table and the
two FAQs now say that.
3. MCP page: the npx/--url snippet (no bin, no such flag) is replaced by the
documented `claude mcp add --transport http` command and the
`{"type":"http","url":".../api/v1/mcp"}` client config
(connect-mcp.mdx:26-32, 85-94).
4. Templates page: the real eight-template catalog (todo, contracts,
procurement, compliance, helpdesk, content, hr, project;
templates.mdx:19-28) replaces the six invented starters; the decision
table now carries the documented install / fork / publish paths
(templates.mdx:33-62). llms.txt follows the page description.
5. Trust center: the lead quotes E1 (Cloud / self-managed split), the
"Self-hosted" tile becomes "2 editions", the data-residency and
licence-check items follow index.mdx:63-77 and license.mdx:150-152,
and the model-training FAQ says only what the Privacy Policy says
(legal.ts:414-415).
6. Pricing: Business Self-Managed (single node, 10-25 AI seats,
$540 / AI seat / yr, bring your own model; license.mdx:41, 64-69) is a
labelled option under Business, the billing note no longer says
self-managed is Enterprise-only, and the Business governance line names
the AI governance row. Team/Business headline prices untouched (#214).
7. zh home tile "数据不出网" -> "数据驻留" (= en "Data residency"); ja
security tile "0 既定の外部テレメトリ" -> the en "Agent inherits user
identity" tile.
zh-Hans changed with en; zh-Hant for marketing pages and pricing derives
at build time (registry deepS2t / toHant), so no generated file changes.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
Article tables on a phone no longer run each row onto one line. Cells wrap, each column keeps a 6.5rem floor (three columns fit the 328px column of a 360px phone), and overflow-wrap: anywhere keeps an overflowing table at exactly its column count x 104px. The widest English table goes from 2,035px to 328px at 360x800; the 12 tables with four to six columns still scroll, to at most 625px, with the always-on scrollbar and trailing-edge fade they already had. Code blocks get the same two scroll affordances at every width, since they overflow at desktop sizes too. The marketing decision table drops its 760px floor on a phone and takes the same wrap, floor and affordances. Every scroll container is keyboard-reachable and named: post tables get tabindex="0" and aria-labelledby their section heading (the article title when no heading precedes them); code blocks keep Shiki's tabindex and gain role="group" with a "Code (lang)" name; the decision-table wrapper becomes a named, focusable group. group, not region, because a region is a landmark and duplicate names fail axe landmark-unique. Body images become links to their own asset: one tap opens the diagram in the browser, where pinch zoom takes its smallest text past 11px. The link's name is the image's alt text. No SVG changes; desktop geometry of tables and images is unchanged. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
The marketing artifact code block is a scroll container; it gets
tabindex="0" and role="group" named by its section heading, like the
other scroll containers, which clears the last axe
scrollable-region-focusable finding on the marketing pages.
The security page's data-residency table had the same 620px floor as the
decision table, which put its third column ("Leaves boundary?") wholly
off-screen on a phone. It takes the same phone block: cells wrap, a
6.5rem column floor, tighter padding, type size and tracking, and the
scrollbar and trailing-edge fade if it still overflows. Its wrapper is
focusable and named by the section heading. Desktop is unchanged.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
…e build Six visible defects from the 2026-10-06 site audit, plus the gate that would have caught the broken links. - Home "Latest insights": the cover's height attribute became CSS height:500px and beat aspect-ratio, cutting each 16:9 cover to a 344x500 sliver. height:auto, a 16:9 box (the covers' own ratio), contain for the odd 16:10 cover. - Blog index: the feature card spanned three rows beside three cards (~1470px) with ~670px of content, leaving an ~800px band. It now takes its own row, cover beside text from 1024px, the other three below. - 404: a top-level 404.html (Cloudflare Pages otherwise runs the site as an SPA and answers every missing URL 200 with the language redirect) and a localized one per locale at <locale>/404.html, moved there by a small build hook. noindexed, no canonical, out of the sitemap. - Cluster pages linked topic hubs not built in de/es/fr/ja/ko. They now link the hub really built (this locale's, else English), and content-lint --dist fails on any internal link to a page the build did not produce. - Footer links and the drawer's close label come from the i18n copy. - Alignment: .item > span; align-content:start on capability cards and next-page links; no min-height floor on template/compare cards; pricing cards on a subgrid; footer brand nowrap; the phone rule now clears the odd capability items' border; non-breaking space before card arrows. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
The browser check of the German drawer showed a visible English "Theme" button beside the localized menu, the same defect as the footer links and the drawer's "Close" label: hard-coded English in the shared chrome. The theme toggle's name and tooltip, the language switcher's "Language:" prefix, the three nav landmark names and the home page's topic-cluster nav name move into the i18n copy for all eight locales. English output is byte-identical. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
…e to 4.5:1, fix three near-misses
The 11 text lines the audit measured running past their cards, now inside
them with room left for wider system faces:
- self-hosted-runtime-en: the Runtime and AI Agent subtitles and the
"Only approved minimum context is sent" line wrap to two lines (the
Runtime line no longer runs over the green connector).
- ai-wrote-your-app-dare-to-merge cover (en and zh): the two captions beside
the Merge pills wrap to two lines centred on the pill; "+8,142 -0" is
right-aligned 22px inside its panel in both locales.
- ai-ontology-open-protocol cover-en: the two dark pills widen to 264px and
the lock moves to the panel's lower right.
- tool-call-lifecycle-en: card 1 rewrapped ("change as Zhang, not as / an
anonymous superuser").
- natural-language-to-app-metadata cover (en and zh): the spec card widens
to 350px with 282px pills and sits 14px lower, so it no longer clips the
subtitle's descenders; the arrows shorten to keep their gaps.
- one-definition-four-surfaces-en: the audit line is reworded to fit.
Contrast: defect-layers-en .none goes from #8a9c95 (2.7:1) to the house
.soft #5d736b.
Near-misses: review-gate-doors-en gives the gap card 12px more height and
the 29/16 numerals 6px more air; self-hosted cover paints the ObjectOS label
after the connectors (both locales), as the runtime diagram already does;
review-loop centres its card row (40px each side, both locales) so card 4's
shadow is no longer clipped.
Also objectos-automation-engine cover-en: the run's meta line ran 40px
under the "selected" tile, hiding "runAs: user"; it drops the "duration:"
label to clear the tile.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
…their alt text Four English and five zh-Hans posts used AI-illustration webp images whose alt text was only "Concept diagram" / 「概念示意图」. Each alt now says what the picture shows (customer hub, read-only connection into a governed object layer, tangled vs. modelled architecture, factory systems feeding an object layer, legacy stack to structured model). The images are unchanged. zh-Hant regenerated with pnpm gen:zh-hant. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
…ed over it scripts/svg-text-fit.mjs measures every <text> in content/blog's SVGs and fails when its box runs more than 2px past the filled shape under its centre, or when a filled shape painted after it covers more than 2px of it both ways. The #193 sweep only caught overflow that also dropped contrast, and a tightest-rectangle measure sees an overflowing line's whole background as its container; this gate measures each line against the shape it sits on. It runs without a browser, since CI installs none: text is laid out with Arial advance widths (scripts/lib/svg-text-metrics.mjs, generated from the metric-compatible Liberation faces by scripts/gen-svg-text-metrics.mjs) and the browser's text-box rules. Against headless Chromium on the same files it agreed to within Chromium's whole-pixel rounding and found the same crossings. A deliberate crossing goes in the documented allowlist; a stale entry fails the gate. Wired into `pnpm build` after content-lint --published (so CI's Build step runs it) and exposed as `pnpm svg:lint`. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
Measured with DejaVu Sans (the default sans-serif on Debian/Ubuntu desktops, about 15% wider than Arial), two of the refitted lines still ran past their shapes: the spec pills on the natural-language cover widen to 290px (both locales), and tool-call-lifecycle card 1 keeps its original words, rewrapped as 'change as "Zhang"—not / an anonymous superuser'. The gate's header states its Chromium agreement as "2px", not "2.0px". Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
…E1 attribution across 18 posts
Post-side update sweep for the 2026-10-03 positioning (work-order item 3):
- CTA: the stale `npm i -g @objectstack/cli && os start` block in 15 en
posts and their authored locales becomes the objectstack README path —
`npm create objectstack@latest my-app && cd my-app`, point a coding
agent at it, `npx os dev --ui`. `how-to-self-host-objectstack` keeps
`os start` (the production server), as does the mid-article operator
line in `enterprise-ontology-race-open-vs-closed`.
- Descriptor: "the commercial production platform" / 「生产平台」 and the
ja/de/es/fr/ko equivalents in ai-ontology-open-protocol,
beyond-agentforce-copilot-open-runtime,
enterprise-ontology-race-open-vs-closed, forward-deployed-engineer-tools
and self-hosted-ai-app-platform become positioning[locale].descriptor
verbatim ("the commercial runtime environment built on ObjectStack").
- Attribution: extend-existing-systems-with-ai scopes "ObjectOS runs in
your environment" to self-managed (ObjectOS Enterprise) or the open
runtime per E1 and objectos license.mdx (Cloud operates the model);
objectos-action-tools attributes the Action mechanism (opt-in exposure,
confirmation, permissions, audit) to the open ObjectStack runtime and
positions ObjectOS with the descriptor. objectos-agent-permission-
boundaries already attributed everything to ObjectStack; untouched.
- `updated: 2026-10-06T10:00:00+08:00` on every corrected file; no `date`
touched, no slug changed. zh-Hant regenerated with gen:zh-hant.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
…ullet matches the FAQ The bullet ended "Business data and prompts never leave your perimeter", which is false once an external AI provider is configured — the FAQ answer in the same post already says so. It now states the same claim as the FAQ: business data stays in your database; prompts go only to the AI provider you configure (on ObjectOS Cloud the model is operated for you); permissions decide what the model sees. en and zh-Hans carried the false sentence; ja, de, es, fr and ko had no sentence about prompts and now carry the corrected claim too. zh-Hant regenerated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
…re menus, tap targets Contrast. --muted is #626c66 light (5.43:1 on --surface) and #8a968d dark (5.42:1); the light accent moves to #3d58f0 (4.67:1 on --surface-sunk); the focus ring is the solid accent (5.44:1 light, 7.06:1 dark) instead of a 28% tint (1.5:1). The home, evidence, security, glossary and cluster accents get dark-theme overrides (green #3fbf8f, blue #8fa3ff, amber #e3a54a, red #f08a8a, all >= 6.9:1 on the dark surface) and the light amber darkens to #93600f (5.34:1). The status pill reads theme tokens (published 5.03:1 light, 6.19:1 dark on its tint), the pricing Team CTA is --surface on --accent (7.06:1 dark, was 2.36:1), and code blocks use Shiki's github-dark-default (comments 6.15:1, were 3.04:1). Keyboard. The mobile drawer moves focus to its close button, makes the page behind it inert, keeps Tab inside it and returns focus to the menu button on close. The header dropdowns drop role=menu/menuitem for the disclosure pattern (aria-controls, plain links, Escape returns focus, focus leaving a menu closes it). Tap targets. The hamburger, drawer close, header CTA and wordmark are 44px tall below 900px; footer links 24px (44px on a phone); the glossary and post back links 44px without moving the text; card title links cover their cards. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
…posts `[ObjectOS](https://www.objectos.ai)` in business-app-in-16k-tokens (1 per locale) and forward-deployed-engineer-tools (2 per locale) pointed at the root language-redirect page, so every click took a meta-refresh hop before landing. Each locale now links its own home (`/en/`, `/de/`, ...); zh-Hant regenerated with gen:zh-hant (it rewrote `/zh-Hans/` to `/zh-Hant/`). 24 links in 16 files; no `date` or `updated` touched — a link target is not a substantive revision. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
…hubs, OG The site-wide audit on #219 measured, on a build of this tree's base: - Sitemap: 190 noindexed fallback pages listed. The sitemap filter now reads each built page and lists it only when it is indexable and its own canonical (astro.config.mjs) — one rule for every fallback producer instead of a second copy of each registry. - Pricing/privacy/terms/refunds in ja/de/es/fr/ko rendered the English copy, self-canonical and in every locale's hreflang (20 pages); security in those five locales inherits five English sections. They now follow the marketing fallback rule through src/lib/fallback.ts: noindex, canonical to /en/, hreflang only for authored locales, the switcher still offering every locale. Authored-ness is derived, not listed: a copy table entry that is the English object is a fallback, and a security locale that inherits any English field is one. - Base takes `contentLocale`: a fallback body gets `<main lang="en">` (marketing, glossary, pricing, legal; 210 pages had English under lang="de"/"ja"/...), og:locale follows it. The security page marks each inherited English part `lang="en"`; glossary fallbacks mark the reader-locale article links with their locale. - The 35 non-English cluster pages are one template per locale: noindex, canonical to the English cluster, out of hreflang and llms.txt. - Pillar pages: every footer links the five clusters (FooterClusters), and each post a cluster lists as its reading path links back to it. - Topic hubs listing fewer than 3 posts (51) are noindexed and out of hreflang; every hub gets BreadcrumbList and ItemList JSON-LD and a "<topic> · Articles · ObjectOS" title. - Root `/`: absolute hreflang, canonical /en/, no noindex beside the meta refresh. SecurityPage links its post with the trailing slash. - public/_headers caches the hashed /_astro/* files as immutable. - og:locale uses en_US/zh_CN/zh_TW/...; og:image:alt everywhere and width/height when known; Organization gets @id and sameAs (the GitHub organization only); SoftwareApplication gets offers from the pricing page's own dollar plans. - Titles: marketing titles that already name ObjectOS no longer append " · ObjectOS" (27 indexable doubles); the blog index takes the home page's heading for it instead of "Articles · ObjectOS". Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
… lint warns at 60/160 seo:smoke reads every built page once and asserts invariants over the whole build instead of per-registry URL lists, so a new fallback producer that gets the rule wrong fails without this script learning about it: 1. every sitemap URL is a built, indexable, self-canonical page; 2. every canonical and hreflang target is indexable and self-canonical, and a page that canonicalizes elsewhere is noindexed; 3. every block of prose in <main> is written in the language its nearest `lang` declares (scripts/lib/text-lang.mjs: script for CJK, distinctive stopwords for en/de/es/fr; too little text is "cannot tell", never a pass), with a per-locale control so it cannot measure nothing; 4. no internal link, canonical, og:url or hreflang redirects (missing trailing slash, `/`, `.../index.html`, a `_redirects` source). Plus the root page (canonical /en/, absolute hreflang, no noindex beside the refresh), `_headers` (rule present, every /_astro/ name hashed), og:locale values, og:image:alt, the Organization @id/sameAs, hub BreadcrumbList/ItemList, and the clusters: English in llms.txt and the sitemap, the templated locales noindexed and canonical to English. On the base build this reports 190 noindexed sitemap URLs, 215 pages whose <main> is in another language than declared (25 indexable), and 9 redirecting link targets on 24 pages; on this branch's build it passes. content-lint: a title wider than 60 columns or a description wider than 160 (CJK counted double) is a warning. Posts first published before the check are counted in one summary line; `--snippets` lists them all. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
…uled The home page's SoftwareApplication listed Offers derived from the pricing page ($20/$45 per AI seat billed annually). That price is under a pending maintainer decision (www#214: $20/$45 annual on www vs $24/$54 monthly on docs), so structured data must not assert either until it is ruled. The rest of the item-10 changes stay: the publisher still references the Organization @id. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
…ith srcset The post header cover is the LCP element on a phone, and 80 posts used a 1.5-1.9 MB PNG for it (ai-agent-workbench: 9.96 s LCP at 390x844, 4x CPU, slow 4G). Card thumbnails on the blog index, topic hubs and the home page downloaded the same full-size file, about 18.8 MB for one scroll of /en/blog/. A PNG or JPG cover now goes through <Image> as WebP with a width list and `sizes` per slot: - post header: 480-1600w, `(min-width: 908px) 860px, calc(100vw - 2rem)`; still eager with fetchpriority=high, since it is the LCP element; - ArticleCard: 360-1280w, sized for the three-column grid, the two-column tablet grid and the phone column; the feature card's cover is eager, as it opens the blog index above the fold; - the home page's latest-posts grid: 360-1080w. The ai-agent-workbench cover at 1280w is 58 KB (the original is 1.62 MB); a 1:1 crop of the 1280w WebP against the original resized to 1280 shows no visible loss (PSNR 39 dB). SVG covers are a few KB and scale themselves, so they keep a plain <img> on the original. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
209 posts (41 English) named an SVG as og:image, twitter:image and BlogPosting.image, and X, LinkedIn, Facebook and Slack draw no card for an SVG; the PNG-cover posts named a 1.5-1.9 MB 1672x941 file. `scripts/og-images.mjs` runs in `pnpm build` after `astro build` and renders one 1200x630 JPEG per cover into dist/og/blog/<post>/. The URL rule lives in scripts/lib/og-image.mjs, shared with BlogPost.astro, which now names that file in og:image, twitter:image and BlogPosting.image, with og:image:width/height 1200x630 and the post title as og:image:alt. The URL carries a hash of the cover's bytes, so an edited cover gets a new URL the networks re-fetch. The step is driven from the built HTML and fails the build when a page names a share image no cover maps to, or declares a size that is not the file's. SVG text: sharp draws it through librsvg/Pango/fontconfig, which use the machine's fonts. The covers ask for -apple-system, Segoe UI, Inter, Arial and Menlo, and CI's ubuntu-latest has no CJK font, so the Chinese covers would have shipped as rows of boxes. The rasterizer now sees only bundled OFL faces (scripts/lib/og-fonts.mjs): Arimo and Cousine (the Arial and Courier New metrics `pnpm svg:lint` measures with), Noto Sans SC, Noto Sans Symbols 2, Noto Sans Math and Noto Emoji. Fontsource ships them as WOFF2, which the FreeType in sharp's libvips cannot read (every glyph drew as a box), so wawoff2 decodes them to TrueType in a temp dir that a generated fonts.conf points at. Before rendering, every character in each SVG cover's text is checked against the faces' cmaps; one that no face has fails the build with the cover, code point and line. On this tree that check first found U+21B6 and U+2298, which is why Noto Sans Math is in the set. An SVG cover is drawn whole at 1120x630 and widened by repeating its edge columns (two covers put a caption in the bottom 23px a centre crop would cut; no cover draws in its outer 40px). A PNG/JPG cover is an illustration without text and is centre-cropped. Rendering SVG text on several libvips threads in one process crashed (SIGSEGV or "SVG rendering failed" in 4 of 6 runs), so SVGs are rendered in three worker processes, one at a time each. Result: 87 images, 5.2 MB, 12 s of the build; zh-Hans, zh-Hant, ja and ko rasters looked at, no missing glyph. New devDependencies, all build-time only: wawoff2 (MIT) and six Fontsource packages (OFL-1.1). sharp was already a dependency. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
…, dim it in dark mode The 1,732,355-byte hero PNG was loaded eagerly by the 120 marketing evidence pages, where it renders in a 415x340 box, and lazily by the home page; it was also the default og:image of every page without its own. - src/assets/objectos-home-hero.png (moved from public/images/) goes through <Image> as WebP with srcset (400-1586w). The box crops the 1.6:1 image with object-fit: cover, so `sizes` asks for the drawn width, not the box's: 545px on a desktop, about 1.2x the box on a phone. - Marketing evidence pages: measured at all six AGENTS.md viewports, the hero's top is at 172px on a desktop (the LCP element there) and at 713-781px on an 800-844px phone, so it is in the first screen everywhere and stays eager, now with fetchpriority=high. Home: it sits under the hero and the video at every viewport, so it stays lazy. - Dark theme: the white render was a glaring block on the dark page. There is no dark variant of the render, so the image itself is dimmed (brightness 0.6, contrast 1.12; three strengths compared side by side in the hero panel). No layout CSS changes. - Base.astro: the default share image was the 1586x992 PNG by its public path, which the move removes. It is now getImage() of the same asset, a 1200x630 JPEG (94 KB). og-images.mjs now checks every og:image that is a file of this site against the size its page declares, the default included, and fails on one that is not in dist/. The hero component code no longer reads the import's width, so Astro drops the 1.7 MB original from dist/ (nothing references it). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
…m at A standard card's 16:10 thumb crops the 16:9 cover with object-fit: cover, which draws the image 1.11x the card's width: a 343px card at 1366 needs 381px of image, and `sizes` said 360px, so the 360w candidate was stretched and visibly soft next to the original. `sizes` now names the drawn width (400px on a desktop, 55vw - 2rem on the two-column tablet grid; the phone's 2:1 thumb is drawn at the card's width) and the list starts at 400w. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
The reading-path cards on the 40 cluster pages still used the original cover: /en/ai-native-app-platform/ loaded four original JPGs (240-370 KB each) into 180px-wide slots. A raster cover now goes through Image as WebP, 400-1080w. `sizes` is the drawn width, the same way as the ArticleCard thumbs: above 640px the image is a 180px (150px at 641-900px) column as tall as the card's text, measured 190-227px, and object-fit: cover fills it by height, so a 1.6:1 or 16:9 cover is drawn 304-364px wide; `sizes` says 400px. On a phone the card stacks and the 16:9 image is drawn at the column width. SVG covers keep the plain img. Only the frontmatter (the import and the width list replacing coverSrc) and the card markup change; no CSS. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
…ns to hold theirs A filter paints nothing outside its region, and 27 blog SVGs set regions (x/y -20%, width 140%, height 150%) too small for the shadow they cast: 81 of 181 filtered elements had part of their feDropShadow on the canvas but outside the region, 35 of them within 1.5σ of the shadow's edge. The worst left a hard horizontal edge, a 15-of-255-level step, across the shadow just under its card. svg-text-fit gains rule 3: for every filtered element it takes the object bounding box, the painted box, and the shadow's offset and blur, and fails unless the shadow's 3σ reach lies inside the filter region wherever it lands on the canvas. Anything but a single feDropShadow fails as unmeasurable. Red on stack/www-wave1 (81 cut shadows in 27 SVGs), green after. Each cut filter's region now holds the full 3σ reach of every element that uses it, rounded out to 10% and only ever grown. Headless Chromium renders every filtered SVG identically to the same SVG with unbounded regions; 14 SVGs change pixels, all outside the old regions. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
… de, es, fr, ja and ko The de, es, fr, ja and ko versions of crm-ai-understands-customers, extend-existing-systems-with-ai, low-code-vs-ai-native-app-platform and manufacturing-legacy-systems-ai still gave their AI illustrations the placeholder alt "Konzeptdiagramm" / "Diagrama conceptual" / "Schéma conceptuel" / 「概念図」 / "개념 다이어그램". Each now carries the descriptive alt the English and zh-Hans posts got, translated, in the post's own terms (AI, governte / gobernada / gouvernée objects). The images are unchanged. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
…card In natural-language-to-app-metadata's cover-en.svg and cover.svg, the Runtime card's gold cycle ring (radius 90, stroke 5, centre 172px down the card) reached 264.5px down a 248px card: it ran 16.5px past the card's bottom edge onto the page background. The card is now 290px tall, which centres the ring in the space under the card title with about 26px clear above and below. The ring, the title, the two lines inside the ring and the card's top edge do not move. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
…K characters A soft line break in a hard-wrapped zh-Hans paragraph reaches the HTML as a segment break, and Chromium and Safari render it as a space mid-sentence (CSS Text 3 removes it; those engines do not yet). The built site had 202 of them: four zh-Hans posts and their generated zh-Hant copies. A rehype step (scripts/lib/cjk-segment-break.mjs, wired in `markdown.rehypePlugins`, inherited by MDX) removes a break whose neighbours are both East Asian Wide/Fullwidth/Halfwidth and not Hangul, which is the CSS rule itself. Code, pre, kbd and samp are opaque; a break between CJK and Latin, after an ambiguous-width dash, or between Hangul words stays a space. The sources stay as written, and every future post in every CJK locale is covered. `content:lint --dist` gains the guard: it reads the built HTML with its own scanner and fails on any segment break left between two such characters in prose, on every built page, so it also catches the step not running or CJK prose arriving by a road the step never sees. A build with no zh or ja page (by `html lang`) fails as unmeasured. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
Astro's smartypants curls an ASCII quote by its neighbours: after a letter it is a closing mark. Chinese puts no space before a quotation, so the opening mark of a pair after a character came out closing, 「大多数”给你的业务加上 AI”的说辞」. It also reads the whole document as one string, so a heading or table cell that starts with a quote took its direction from the block before it, which is the Korean case. The built site had 664 such marks on 41 pages: 324 in 16 zh-Hans posts, the same in their zh-Hant copies, 15 in 8 ko posts and 1 in ja. A remark step (scripts/lib/cjk-quotes.mjs, wired in `markdown.remarkPlugins`, inherited by MDX) runs after smartypants on zh-Hans, zh-Hant, ja and ko files only, chosen by file name. In one paragraph, heading or table cell it sets each mark by its position in its pair: the first opens, the next closes, whether the source typed it straight or curly. Single marks pair the same way except an apostrophe (after a Latin letter or digit, or `'26`), which stays ’. Code is opaque, the marks stay “ ” / ‘ ’ as the sources type them (AGENTS.md records no zh style), and 「」 is never touched. The sources stay as written and every future post in those locales is covered; an English, German, Spanish or French page is not touched (whole-site byte diff: only the 41 pages above change, and only quotation marks in their prose). `content:lint --dist` gains the guard: it reads the built HTML of every zh, ja and ko page with its own scanner and fails on a curly mark whose direction disagrees with its position, or a pair left open in its block, so it also catches the step not running or CJK prose arriving by another road. A build with no such page, or no curly mark on one, fails as unmeasured. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
…xport `pnpm wechat` / `wechat:draft` render through marked, not Astro, so neither the rehype step that removes a line break between two CJK characters nor the quote step reaches the export. The four hard-wrapped zh-Hans posts exported with 20, 40, 23 and 18 such breaks (counted by the `content:lint --dist` scanner), which the WeChat reader shows as spaces mid-sentence, and marked leaves ASCII quotes straight. `toWechatHtml` now walks marked's tokens (`walkTokens`) and, per paragraph, heading, list-item text and table cell, applies `collapseCjkSegmentBreaks` with neighbours read across bold, emphasis and links, and `directQuotes` when the block holds a CJK letter. Code spans, images and raw HTML bound the first and are opaque to the second; fenced code is never seen. An export of the four posts now differs from before only by the 101 removed breaks and six straight pairs curled “ ”, all opening marks first; an English export is byte-identical. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
#226's quote step ran on zh, ja and ko only. The same fault shows in the Latin locales, because smartypants reads a document as one string: a heading or table cell that opens on a quote takes its direction from the block before it, so an English table printed ”What exists in our business?”, and an elided century came out as Next ‘26. German types „Wort" and smartypants curls the ASCII closer as the English ”, which German never prints after „. The built site had 366 such marks on 29 pages: de 344 on 17, en 16 on 7, es 5 on 4 and fr 1 on 1. The step is now `remarkQuotes` and runs on every Markdown and MDX file. It keeps #226's rule: in a paragraph, heading or table cell, the first mark of a pair opens and the next closes, and an apostrophe, including '26, is ’. It adds one rule with no per-locale branch: a low mark („ or ‚) only opens, and its pair closes with “ or ‘. The author's choice of mark is kept, so « » and „ “ stay as typed, and an ASCII pair stays “ ” as smartypants prints it. Code is opaque, as before. `content:lint --dist` now reads every built page, not only zh, ja and ko, with the same rule, including the low-mark pairs. Its summary lists the pages and marks it read per `<html lang>`. A build with no page, or no curly mark, fails as unmeasured. The whole-site byte diff shows exactly those 366 characters on those 29 pages. Each one is a quotation mark replaced by another, outside tags and outside code. No zh, ja or ko page changes. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
This was referenced Oct 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #227
Branch. This PR carries 1 commit(s) from the seat's linear stack, replayed onto
main@6308287. The replay is patch-equal togit diff c1e07ed lin/issue-227, with byte-identical diffs. Head:3263dff. The staged SHAs quoted below are the pre-replay commits; their content is unchanged.Quotation marks now point the right way in every locale. #226's quote step ran on zh, ja and ko only. It now runs on every Markdown and MDX file, and it learns German's low marks.
What was wrong
Headings and table cells. Smartypants reads the whole document as one string. A heading or table cell that starts with a quote therefore takes its direction from the last letter of the block before it. /en/blog/forward-deployed-engineer-tools/ printed every client question in its table as
”What exists in our business?”.Elided years.
Next '26came out asNext ‘26. The apostrophe of an abbreviated year is’.German. The de sources type
„Wort". Smartypants curls the ASCII closer as the English”, so de pages printed„Wort”, a closing mark German never uses after„. German closes„with“. The two‚Wort'pairs had the same fault.Extent. The extended guard, run on Chinese posts print quotation marks the wrong way round: smartypants turns ASCII quotes after CJK text into closing quotes (≈300 pairs in 16 zh-Hans posts, plus zh-Hant and ko); the WeChat export keeps CJK line-break spaces #226's build (
36115b3), finds 366 marks on 29 pages. An independent scratch scanner, with its own tokenizer, gives the same counts.„…”closers, 2 headings or words opening on”, 2‚…’closers, 1‘26”, 4‘26”, 1‘26‘26en, es and fr match the card's counts exactly (16 on 7, 5 on 4, 1 on 1). The card did not measure de; this run did.
The fix
remarkCjkQuotesbecomesremarkQuotes(scripts/lib/cjk-quotes.mjs), wired in the samemarkdown.remarkPluginsentry. It no longer picks files by locale and runs on every Markdown and MDX file.’. That coversdon't,users'and the elided year'26.„and‚only open, and the pair they open closes with“or‘(LOW_OPENERS). The rule has no per-locale branch, because the German pairs follow from the marks the source typed. de is the only locale that types low marks.« »,„ “and「」stay as typed. An ASCII pair stays“ ”in every locale, as smartypants prints it. Code, inline code and images are opaque, as before.scripts/lib/wechat-html.mjs, not touched) importsdirectQuotesfrom it. Its output is byte-identical for all 339 post sources, compared with the base library.Guard.
content:lint --distreplacescheckBuiltCjkQuoteswithcheckBuiltQuotes:lang. The failure line gives the count per locale.Evidence (HEAD e4c2a6b, base 36115b3)
content lint --dist failed (366 quotation marks pointing the wrong way or left open on 29 pages: de 344 on 17, en 16 on 7, es 5 on 4, fr 1 on 1)quotation marks passed (880 built pages, 11751 curly marks, 0 pointing the wrong way or left open; … de 105/965, en 127/1918, es 105/648, fr 105/4006, ja 105/136, ko 105/964, zh-Hans 114/1557, zh-Hant 114/1557)Whole-site byte diff (a script compares every file and classifies every changed character):
Same file set. 1081 files before and after. 1052 are byte-identical, including every ja, ko, zh-Hans and zh-Hant page, every asset, the sitemaps, the RSS and
llms.txt. CJK output is unchanged from Chinese posts print quotation marks the wrong way round: smartypants turns ASCII quotes after CJK text into closing quotes (≈300 pairs in 16 zh-Hans posts, plus zh-Hant and ko); the WeChat export keeps CJK line-break spaces #226.Exactly the guard's pages. The 29 that differ are the guard's 29 pages: de 17, en 7, es 4, fr 1.
Every change is a quotation mark. 366 characters changed:
”→“×357 (de 341, en 12, es 4);‘→’×7 (de 1, en 4, es 1, fr 1);’→‘×2 (de).Each one is a quotation mark replaced by another, one for one. None is inside a tag, or inside code, pre, kbd, samp, script, style, title, textarea, svg or math.
Diff and guard agree. The per-locale counts equal the guard's red counts.
Final build matches. The final build's dist is sha256-identical, file for file, to the measured after-dist.
Probes (one-off, not shipped):
directQuotescases pass. They include Chinese posts print quotation marks the wrong way round: smartypants turns ASCII quotes after CJK text into closing quotes (≈300 pairs in 16 zh-Hans posts, plus zh-Hant and ko); the WeChat export keeps CJK line-break spaces #226's CJK cases unchanged,„Wort"→„Wort“,„Wort”→„Wort“,‚Wort'→‚Wort‘,‚nested in„, ASCII pairs in de staying“ ”, French elision next to« », and’26.„…”,‚…’, a heading opening on”, an unclosed„and‘26on a de page, plus a cell opening on”and a„…”on an en page. Correct pairs, nested singles, apostrophes, a quote around code, and code, pre, title and script contents are not flagged.Gates at e4c2a6b:
pnpm checkResult (145 files): 0 errors, 0 warnings, 0 hintspnpm buildVERDICT command-exit 0. Inside: zh-Hant generated 46, content lint passed (339 files), svg-text-fit OK, and all four--distchecks pass.pnpm content:lintcontent lint passed (339 files, 44 glossary terms checked)pnpm content:lint --publishedpnpm content:lint --distpnpm seo:smokeSEO smoke test passed (870 HTML pages checked)pnpm svg:lintsvg-text-fit: OK … (112 SVGs, 2244 text boxes, tolerance 2px, 1 allowlisted)Screenshots. Three pages, before and after, at the six AGENTS.md viewports (1366, 1440, 1512, 1920, 390, 360), each with the changed element outlined:
”What exists…becomes“What exists….Next ‘26becomesNext ’26.‚hohes Betrugsrisiko’ … Kunden.”becomes‚hohes Betrugsrisiko‘ … Kunden.“.There are also 2x crops of a de paragraph full of
„…”(now„…“), the en, de and es headings that opened on”, and the fr’26cell. No page scrolls horizontally at any width. A pixel diff of each pair is confined to the changed marks, plus the subpixel shift of the rest of a line where’and‘differ in width; line breaks are the same.Acceptance notes
de ASCII pairs print English-style. About 61 quotations on 15 de pages were typed as an ASCII pair (
"…"), so they print“…”, not German„…“. One example is the h2“Eine Spalte hinzufügen” ist im Unternehmen nicht einfach. Their direction is now right; their style is the source's choice. Changing it would need a German-specific style rule, which is not in this card.Straight quotes from templates and data. Pages rendered from
.astro/.tsdata carry straight ASCII quotes in prose. Examples are the legal pages in every locale (("Terms")), several glossary pages and /zh-Hans/approvals/. Straight marks have no direction, so the guard does not read them. This is polish, noted only.Limits of the per-block rule in Latin text. These cases surface as a red build, not silent damage:
'til,'em) would read as an opening mark;Neither exists today: outside the 366 fixes, the rule changed nothing on the 442 en, de, es and fr pages.
The low-mark rule has no locale branch.
„…”is flagged and set to„…“on any page. That would be wrong only for a Polish- or Hungarian-style quotation, and the site has no such locale.Guard change. The unmeasured check is now "no built page, or no curly mark anywhere", instead of "no zh, ja or ko page". The per-language list in the pass line shows which locales were read.
astro.config.mjs scope. Besides the
remarkPluginsvalue, the commit changes the import line that names its plugin (renamed) and the comment that documents the entry. Nothing else in the file changes.Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr