clauditDashboardclaude-opus-5

SEO Basics

SEO · 7/28/2026, 4:22:56 PM · 15,423 chars

This is a technically disciplined static build: every one of the 71 pages has exactly one H1, a self-referencing canonical, unique title and description, apex-consistent sitemap URLs with content-hashed lastmod, and unusually rich validated structured data. The real gaps are a homepage that discards its own server-rendered product grid in JavaScript (`grid.innerHTML=''`), a two-hop www redirect chain caused by trailing-slash normalisation running before the host rewrite, and roughly 37 pages whose titles or descriptions overflow SERP display limits. Heading semantics and internal-link anchor text have smaller, template-level defects that are cheap to fix in the generator.

Title Tags 6/10Meta Descriptions 7/10Heading Structure 6/10URL Structure 7/10Internal Linking 6/10Image SEO 8/10Content Quality 8/10Composite 6/10

12 findingsfirst run of this auditor

  1. highcertain · vulnerabilityInternal Linking

    Homepage JavaScript wipes the server-rendered product grid and rebuilds it client-side

    index.html:1186 — `grid.innerHTML=''` then rebuild from `DUSKGILD_DATA`; static markup emitted by the generator into the `BUILD:CARDS-DARK` / `BUILD:CARDS-OW` markers

    The generator writes complete static `<div class="card">` markup (with real `<a href="/designs/{slug}/">` links, per `home_card()` at gen_design_pages.py:1583) into the two BUILD markers, and then the page's own script at index.html:1186 executes `grid.innerHTML=''` and re-creates the same cards from an in-page `DUSKGILD_DATA` array. The site's single highest-authority page therefore has two different sources of truth for its outbound internal links, and the server-rendered set is unconditionally destroyed on load rather than being progressively enhanced. The `ItemList` JSON-LD is written into a third region (`BUILD:ITEMLIST`) that is not derived from the DOM the browser ends up showing, so any drift between the static markup, `DUSKGILD_DATA` and the ItemList is undetectable by the build gate.

    Fails when: A single bad entry in `DUSKGILD_DATA` (missing `slug`, unescaped quote, undefined field) makes the rebuild loop throw after line 1186 has already emptied the container. The homepage then renders with zero product cards: no internal link from the root to any of the 44 product pages, while the ItemList JSON-LD still advertises them — a structured-data/visible-content mismatch. The same happens for any fetcher that does not execute JS (many social/AI/verification crawlers, and Googlebot's raw-HTML pass), which sees the static set while the rendering pass sees the rebuilt set, producing unstable discovery signals for products that are only present in one of the two sets.

    Fix: Delete the wipe-and-rebuild path. Keep the generator-emitted static cards as the single source of truth and let JS only mutate (filter/sort/lazy-hydrate) existing nodes: replace `grid.innerHTML=''` with an in-place update, e.g. `for (const card of grid.querySelectorAll('.card')) card.hidden = !matches(card.dataset);`. If the JS-built grid must stay, generate both the static cards and the ItemList from `DUSKGILD_DATA` at build time and add a check to `tools/seo_verify.py` asserting the static card slugs, `DUSKGILD_DATA` slugs and ItemList item URLs are identical sets.

  2. mediumcertain · deficiencyURL Structure

    www URLs without a trailing slash take two hops to reach the canonical URL

    vercel.json — `"trailingSlash": true` combined with the last redirect rule `{ "source": "/(.*)", "has": [{ "type": "host", "value": "www.duskgild.com" }], "destination": "https://duskgild.com/$1", "permanent": true }`

    Trailing-slash normalisation is applied by the platform before the host-rewrite rule in `redirects[]` fires, so a www request without a slash is first 308-redirected to the www+slash form and only then 301-redirected to the apex. The measured curl output in §2 confirms `https://www.duskgild.com/gothic-rings` → `308 https://www.duskgild.com/gothic-rings/`, i.e. hop one keeps the non-canonical host. Every external link, QR code, printed URL or pasted address that uses www without a slash therefore costs two round trips and passes through a host that is not the canonical one and is not the host used in `sitemap.xml` or `robots.txt`.

    Fails when: A press mention or Pinterest pin links to `https://www.duskgild.com/gothic-rings`. Googlebot fetches it, receives a 308 to `https://www.duskgild.com/gothic-rings/`, must schedule a second fetch, receives a 301 to `https://duskgild.com/gothic-rings/`, then a third fetch for the 200. On a four-day-old site with 71 URLs and 1 indexed, this triples the crawl cost of every www-sourced entry point and attenuates the link signal through an extra hop.

    Fix: Make the www→apex redirect happen in one hop, before slash normalisation: configure `www.duskgild.com` as a redirect-only domain to `duskgild.com` in the Vercel project domain settings (domain-level redirects run ahead of `trailingSlash`), and delete the `has: host` rule from `vercel.json`. If it must stay in config, emit the canonical form directly: `{ "source": "/:path*", "has": [{"type":"host","value":"www.duskgild.com"}], "destination": "https://duskgild.com/:path*/", "permanent": true }` and verify with `curl -sI https://www.duskgild.com/gothic-rings` that the single `location` is the apex+slash URL.

  3. mediumcertain · deficiencyTitle Tags

    13 titles exceed 60 characters, up to 81 characters on collection and guide templates

    Rendered heads §4 and the length table §6; generated by `collection_page()` (gen_design_pages.py:1134) and `content_page()` (:1736), which append " | Duskgild"

    Measured examples from the submitted rendered heads: `/gothic-rings/` = `<title>Gothic Rings — Skull, Coffin &amp; Thorn Rings in Sterling Silver | Duskgild</title>` at 76 chars, `/skull-ring-meaning/` = `<title>Skull Ring Meaning: Memento Mori &amp; Symbolism, Told Straight | Duskgild</title>` at 74 chars, `/gothic-wedding-bands/` at 81 chars, `/gothic-engagement-rings/` at 71, plus 8 product titles between 62 and 74 chars and the homepage at 65. The systemic cause is that the collection and guide builders append a " | Duskgild" brand suffix (product pages do not), on top of `kw` descriptors that are already near the limit. The homepage additionally leads with the brand (`DUSKGILD — Gothic Jewelry in Solid Sterling Silver, Made to Order`), pushing the head keyword past the first-glance window. Titles are unique and keyword-bearing, so this is purely a truncation/CTR defect, not a targeting one.

    Fails when: A desktop SERP for "gothic wedding bands" truncates the 81-char title at roughly 580px (~60 chars), so the searcher sees the head keyword but loses the differentiators and the brand entirely — Google may also substitute its own title from the H1 (`Gothic Rings`, two words), replacing a keyword-rich title with a bare one and depressing CTR on the site's highest-intent commercial queries.

    Fix: Cap titles in the generator: emit the brand suffix only when the base title is ≤48 chars (`title = base if len(base) > 48 else f'{base} | Duskgild'`), shorten the longest `kw` descriptors (e.g. `Gothic Rings — Skull, Coffin & Thorn Rings in Silver` = 51 chars), and lead the homepage with the keyword (`Gothic Jewelry in Solid Sterling Silver — Duskgild` = 50 chars). Add a hard assertion to `tools/seo_verify.py` that fails the build on any `<title>` over 60 characters, so the gate that already blocks stale sale prices also blocks this.

  4. mediumcertain · deficiencyMeta Descriptions

    11 meta descriptions run 166–181 characters and will be truncated mid-sentence

    Length table §6 — `/designs/heart-necklace-isolde/` 181c, `/designs/vine-ring-tendril/` 178c, `/designs/bat-wing-cuff-umbra/` 176c, `/moon-jewelry-meaning/` 175c, `/designs/skull-ring-undertow/` 175c, `/designs/cross-dagger-earrings-crux/` 174c, `/lab-created-stones/` 174c, plus 4 in the 166–169 range; sourced from the `meta` field of the `P` list (gen_design_pages.py:82) and `CONTENT_PAGES` (:1678)

    Descriptions are unique across all 71 pages and match page content — the defect is length only. Google renders roughly 155–160 characters on desktop and less on mobile, so anything past ~165 is dropped with an ellipsis. Because these descriptions are written as single flowing sentences (see `/designs/skull-ring-vigil-signet/`: "...oxidized by hand and made to order."), the clause that carries the purchase trigger — "made to order", "solid 925 sterling silver" — sits at the end and is exactly what gets cut. Note also that none of the sampled descriptions contains an explicit call to action.

    Fails when: A shopper searching "garnet skull ring" sees the `/designs/skull-ring-undertow/` snippet cut at ~155 chars, losing the "made to order in solid 925 sterling silver" close that differentiates the piece from mass-market results, and no CTA; Google is also more likely to discard the tag and synthesise a snippet from body copy, making the carefully written description dead weight.

    Fix: Rewrite the 11 offenders to 140–155 characters, front-loading the differentiator and adding a short CTA (e.g. "...oxidized by hand, made to order. See the piece."). Enforce it in the generator: `assert 120 <= len(meta) <= 158, slug` in `product_page()`/`content_page()`, and add the same bound to `tools/seo_verify.py` so it cannot regress.

  5. mediumcertain · deficiencyHeading Hierarchy

    Homepage rail headings render the case numeral glued to the collection name

    Rendered DOM of `/`, §5: "h3 text concatenates the case number and the collection name with no separator: \"Case IGothic Rings\" in the rendered DOM" (7 headings: Case I…Case VII)

    The seven "Browse by kind" subheadings on the homepage are the most prominent keyword-bearing structural text on the site's root document, and each one is corrupted by a missing separator in the string that builds them: the rendered text is `Case IGothic Rings`, `Case IISkull Rings`, `Case IIIGothic Necklaces`, and so on. The exact-match phrases "Gothic Rings", "Skull Rings", "Gothic Necklaces" are therefore never present as clean tokens in the homepage heading layer, and the visible label reads as a typo to any human who sees it unstyled. The generator function that assembles these labels was not included in the packet, so the fix location is identified from the rendered markup.

    Fails when: Googlebot tokenises the homepage H3s as "IGothic", "IISkull", "IIIGothic" — junk tokens rather than the seven collection keywords the rail is meant to reinforce, so the homepage's internal keyword-to-collection mapping is lost. A screen reader announces "Case eye-gothic rings". If CSS ever fails to load or is overridden (Reader mode, email/AMP-style extraction, text-only cache), users see the run-together text verbatim.

    Fix: Join the two parts with explicit markup and a separator in the rail card builder, e.g. `f'<h3><span class="case">Case {numeral}</span> <span class="kind">{name}</span></h3>'`, and assert in `tools/seo_verify.py` that no rendered heading contains a lowercase-to-uppercase boundary immediately after a Roman numeral (`re.search(r'\b[IVX]+[A-Z][a-z]', heading_text)`).

  6. lowcertain · deficiencyHeading Hierarchy

    Collection template jumps from H1 straight to H3 for every product card

    Rendered heading outline §5 for `/gothic-rings/`: "h1 Gothic Rings / h3 (one per product card…) NOTE: h3s follow h1 with no intervening h2" — emitted by `collection_page()` (gen_design_pages.py:1134)

    All 7 collection pages produce an outline of H1 → H3 with no H2 level, so the document has no level-2 section for the grid it is entirely built around. Heading levels are being chosen for visual weight (card titles should look smaller) rather than for structure, which is the specific anti-pattern the H1–H6 contract exists to prevent. Product and guide templates get this right (H2 sections with H3 children), so the defect is isolated to one builder.

    Fails when: A screen-reader user pressing "3" to walk the heading outline of `/gothic-rings/`, or an outline extractor (including the machine-readable outline crawlers use to segment a page for passage indexing), sees seven orphaned level-3 headings under a level-1 with nothing between them, so the product list is not attributable to any named section and the page reads as a title followed by unstructured fragments.

    Fix: Insert a section heading above the grid in `collection_page()` — `<h2>All gothic rings in the register</h2>` — or promote the card titles from `<h3>` to `<h2>` and style them down in CSS. Add an outline check to `tools/validate_structured_data.py`/`seo_verify.py` that fails when a heading level is skipped by more than one step.

  7. lowcertain · deficiencyInternal Linking

    Sitewide link to the 44-product hub uses the anchor text "Register"

    gen_design_pages.py:669 `header_html()` — `<nav class="hnav" aria-label="Primary"><a href="/designs/">Register</a>`

    `/designs/` is the hub that links to all 44 product pages and receives the most internal links on the site, yet its sitewide anchor text is the brand-internal word "Register", which carries no product or category keyword and collides with the near-universal web meaning of "register" as account signup. Every other nav anchor is descriptive ("Rings", "Necklaces", "Earrings", "FAQ"), so this is the one link whose anchor text contributes nothing to the target's topical signal.

    Fails when: 71 pages ship a nav link whose anchor text tells Google nothing about `/designs/`, so the hub accumulates internal links with zero keyword relevance for terms like "gothic jewelry" or "all designs". A first-time visitor scanning the header reads "Register" as "create an account", skips it, and never reaches the full catalogue — the hub that carries the two collections absent from the nav.

    Fix: Change the anchor to describe the destination while keeping the brand voice: `<a href="/designs/">All Designs</a>` or `<a href="/designs/">The Register — All Jewelry</a>` in `header_html()`, and use the same descriptive anchor in `footer_html()` (line 677).

  8. lowcertain · deficiencyInternal Linking

    Two of seven collections are excluded from the primary navigation

    gen_design_pages.py:669 `header_html()` — nav contains only `/designs/`, `/gothic-rings/`, `/necklaces-pendants/`, `/earrings-ear-cuffs/`, `/faq/`; `/bracelets-cuffs/` and `/cufflinks/` appear only in the homepage rail, the `/designs/` hub and `footer_html()`

    Five of the seven collection pages receive a sitewide header link; `/bracelets-cuffs/` and `/cufflinks/` receive only footer-level and hub links. Footer links are real links so both pages stay within two clicks of the homepage, but they collect a materially smaller share of internal links than their peers, and the header is the link block that carries the most weight and the most user attention. The gap compounds with the homepage grid defect: if the JS-rebuilt rail is the only prominent in-body path to these two collections, a rebuild failure removes it.

    Fails when: On a four-day-old site where Search Console reported 1 of 39 URLs indexed, `/cufflinks/` and `/bracelets-cuffs/` are discovered only through the footer and the `/designs/` hub, so they are crawled later and rank lower for "gothic cufflinks" / "silver cuff bracelet" than the five collections that get a header link from all 71 pages.

    Fix: Add both collections to `header_html()` (a "More" grouping or a second nav row if horizontal space is tight): `<a href="/bracelets-cuffs/">Bracelets</a><a href="/cufflinks/">Cufflinks</a>`, and assert in `tools/seo_verify.py` that every slug in `COLLECTIONS` (line 1036) appears in the rendered header of every page.

  9. lowcertain · deficiencyMeta Descriptions

    Collection template omits twitter:title and twitter:description

    Rendered head §4 for `/gothic-rings/`: "og:title/og:description present, but twitter:title and twitter:description are ABSENT on this template — only twitter:card and twitter:image are emitted" — `collection_page()` (gen_design_pages.py:1134)

    The homepage and product templates emit the full `twitter:title` / `twitter:description` / `twitter:image` trio; the collection template emits only `twitter:card` and `twitter:image`. X/Twitter itself falls back to `og:title`/`og:description`, so the practical impact is confined to the many other consumers that read the `twitter:*` namespace literally without OG fallback. This is an inconsistency in one of five builders rather than a systemic gap.

    Fails when: A collection URL such as `/gothic-rings/` is pasted into a tool or chat client that parses only `twitter:*` tags (several link-preview libraries and CMS embed widgets do): the preview renders the large image and bare domain with no headline or description text, so the share loses its keyword-bearing hook while the same paste of a product URL renders correctly.

    Fix: In `collection_page()`, emit the same twitter tags as `product_page()`: `<meta name="twitter:title" content="{title}">` and `<meta name="twitter:description" content="{meta}">`. Add a check to `tools/seo_verify.py` requiring the twitter trio on every indexable page.

  10. lowlikely · deficiencyURL Structure

    Homepage is also published at /duskgild-landing.html as a byte-identical copy

    Build model (§ Project context): "`index.html` is then copied byte-identical to `duskgild-landing.html`"

    A second fetchable URL serves the entire homepage document unchanged. Because the copy is byte-identical it carries `<link rel="canonical" href="https://duskgild.com/">` and `og:url` pointing at `/`, which is the correct dedup signal, so the exposure is limited — but the file is neither excluded in `robots.txt` (which only disallows `/hq/`) nor present in `sitemap.xml`, and it duplicates the WebSite/Organization/ItemList JSON-LD at a URL that does not match the `@id`/`url` values inside it. Whether it is actually served depends on host configuration, which was not supplied.

    Fails when: Someone shares or links `https://duskgild.com/duskgild-landing.html` (a plausible artefact URL for an internal or ad-preview handoff). Googlebot crawls it, spends crawl budget on a full duplicate of the site's most important document, and reports it in Search Console as "Alternate page with proper canonical tag" — noise on a site where only 1 of 39 submitted URLs was indexed, plus a second copy of the homepage's Organization markup at a non-canonical URL.

    Fix: Either stop emitting the copy, or make it non-indexable and non-duplicative: add `{ "source": "/duskgild-landing.html", "destination": "/", "permanent": true }` to `vercel.json` redirects, or add `Disallow: /duskgild-landing.html` to robots.txt plus `<meta name="robots" content="noindex">` in that file only (which means it can no longer be a byte-identical copy).

  11. lowlikely · deficiencyImage SEO

    Homepage card images are lazy-loaded unconditionally with no fetchpriority hint

    gen_design_pages.py:1583 `home_card()` — `... sizes="{SIZES_CARD}" alt="..." loading="lazy" decoding="async"` on every card, with no `fetchpriority="high"` branch

    Hub and collection grids give the first image `fetchpriority="high"` and lazy-load the rest (§9), but `home_card()` applies `loading="lazy"` to every card with no exception for the first one. On the homepage this is compounded by the client-side rebuild at index.html:1186: the cards are re-created after script execution, so their images are not in the initial HTML the browser preload-scanner sees, and the largest in-viewport image cannot be discovered early. Whether the first card is actually within the initial viewport depends on hero height, which was not supplied.

    Fails when: On a mobile viewport where the first product card falls above or just at the fold, the browser skips it during preload scanning, then the rebuild replaces the container and requests the image only after JS runs — LCP lands on a late-discovered lazy image, degrading the Core Web Vitals field score for the site's most-linked URL.

    Fix: Mirror the hub behaviour in `home_card()`: accept an index and emit `fetchpriority="high"` with no `loading` attribute for the first one or two cards (`lazy_attr = '' if i < 2 else ' loading="lazy"'`), and add `<link rel="preload" as="image" imagesrcset="..." imagesizes="...">` for that image in the homepage head so it is discoverable regardless of the JS rebuild.

  12. infocertain · deficiencyMeta Descriptions

    Guide pages reuse the generic site OG image instead of a page-specific one

    Rendered head §4 for `/skull-ring-meaning/` — `<meta property="og:image" content="https://duskgild.com/og.jpg">` "[generic site OG, not page-specific]", while product pages use `https://duskgild.com/img/og/{slug}.jpg`

    Per-product OG cards already exist under `/img/og/` and are wired into `product_page()`, so the machinery is in place; the 12 guide pages (`CONTENT_PAGES`, line 1678) fall back to the site-wide `og.jpg`. The collection template takes a third approach, borrowing a product card (`/img/og/skull-ring-vigil-signet.jpg`) for `/gothic-rings/`, which is at least topical but is the same asset a product page uses.

    Fails when: All 12 guides pinned to Pinterest or shared on Reddit/X render the identical Duskgild card, so a user who has already seen one share has no visual cue that a second link is different content; Pinterest in particular deduplicates and down-ranks repeated images, suppressing the traffic channel these long-form guides are written for.

    Fix: Extend the OG card generator to the guide set — emit `/img/og/{slug}.jpg` for each `CONTENT_PAGES` entry — and give each collection its own card rather than reusing a product's. Assert in `tools/seo_verify.py` that no two indexable pages share an `og:image` except the homepage.