diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 4a38675045..a702626d4c 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -24,4 +24,6 @@ uv tool install md-snakeoil && snakeoil --line-length 88 --rules "E,F,B,C4,ISC,P - @docs/README.md — full command reference, content structure, and links to detail docs - @docs/content.md — writing conventions, code blocks, linking, and assets +- @docs/design-system.md — visual language: tokens, logo, nav, search, diagrams; read before any CSS/nav/visual change +- @docs/captures.md — UI screenshots and GIFs: where the pixels come from, browser session, scoping rules, helpers - @docs/caveats/README.md — known gotchas; add new ones as separate files in this folder diff --git a/.claude/docs/README.md b/.claude/docs/README.md index 98f573d18c..766a5eceea 100644 --- a/.claude/docs/README.md +++ b/.claude/docs/README.md @@ -35,4 +35,6 @@ Do not write prose in API reference pages in this repo — edit the docstrings i ## More - @docs/content.md — writing conventions, Python code blocks, linking, assets +- @docs/design-system.md — visual language: tokens, logo, nav, search, diagrams +- @docs/captures.md — how UI screenshots and GIFs are taken: cluster, browser session, scoping, crop and GIF helpers - @docs/caveats/README.md — known gotchas; add new ones as separate files diff --git a/.claude/docs/capture_crop.py b/.claude/docs/capture_crop.py new file mode 100644 index 0000000000..b0fdbc1406 --- /dev/null +++ b/.claude/docs/capture_crop.py @@ -0,0 +1,43 @@ +"""Crop a viewport screenshot to a rectangle measured in the page. + +Usage: + capture_crop.py '' [--margin F] [--top-min Y] + +The rect is the JSON an `agent-browser eval` returns from getBoundingClientRect +plus the viewport width: {"x", "y", "w", "h", "iw"}. The device scale is +derived from the screenshot width divided by "iw", so any viewport works. +--margin is a fraction of the rectangle added on every side (0.03 for a card, +0.18 for a modal). --top-min clamps the crop's top edge in css pixels, used to +keep a sticky header out of the shot. +""" + +import argparse +import json + +from PIL import Image + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("src") + ap.add_argument("out") + ap.add_argument("rect") + ap.add_argument("--margin", type=float, default=0.0) + ap.add_argument("--top-min", type=float, default=None) + a = ap.parse_args() + rect = json.loads(a.rect) + im = Image.open(a.src) + dpr = im.width / rect["iw"] + x, y, w, h = rect["x"], rect["y"], rect["w"], rect["h"] + mx, my = w * a.margin, h * a.margin + x0, y0, x1, y1 = x - mx, y - my, x + w + mx, y + h + my + if a.top_min is not None: + y0 = max(y0, a.top_min) + box = tuple(round(v * dpr) for v in (max(0, x0), max(0, y0), x1, y1)) + box = (box[0], box[1], min(im.width, box[2]), min(im.height, box[3])) + im.crop(box).save(a.out) + print(a.out, Image.open(a.out).size) + + +if __name__ == "__main__": + main() diff --git a/.claude/docs/capture_gif.py b/.claude/docs/capture_gif.py new file mode 100644 index 0000000000..b8af02d9f7 --- /dev/null +++ b/.claude/docs/capture_gif.py @@ -0,0 +1,55 @@ +"""Assemble a GIF from viewport screenshots of successive UI states. + +Usage: + capture_gif.py [bottom margin fraction] + +The directory holds pairs frame_NN.png (a viewport screenshot) and +rect_NN.json (the card's rectangle in that frame: x, y, w, h, iw, plus a +"state" label; a frame without a state is skipped). Every frame is cropped +around the largest rectangle seen, so the card can move between frames, +halved to 1x and quantised. The first frame holds longer, the last longest. +""" + +import glob +import json +import os +import sys + +from PIL import Image + + +def main() -> None: + d, out, margin = sys.argv[1], sys.argv[2], float(sys.argv[3]) + bottom = float(sys.argv[4]) if len(sys.argv) > 4 else margin + frames = [] + for rf in sorted(glob.glob(os.path.join(d, "rect_*.json"))): + with open(rf) as fh: + r = json.load(fh) + if not isinstance(r, dict) or r.get("state") is None: + continue + p = rf.replace("rect_", "frame_").replace(".json", ".png") + if os.path.exists(p): + frames.append((p, r)) + if not frames: + raise SystemExit("no frame_NN.png / rect_NN.json pairs with a state") + first = Image.open(frames[0][0]) + dpr = first.width / frames[0][1]["iw"] + w = max(r["w"] for _, r in frames) + h = max(r["h"] for _, r in frames) + mx, my, mb = w * margin, h * margin, h * bottom + + def box(r: dict) -> tuple[int, ...]: + return tuple(round(v * dpr) for v in (r["x"] - mx, r["y"] - my, r["x"] + w + mx, r["y"] + h + mb)) + + imgs = [] + for p, r in frames: + im = Image.open(p).convert("RGB").crop(box(r)) + im = im.resize((im.width // 2, im.height // 2), Image.LANCZOS) + imgs.append(im.quantize(colors=128, method=Image.Quantize.MEDIANCUT)) + durations = [1500] + [900] * max(0, len(imgs) - 2) + ([2500] if len(imgs) > 1 else []) + imgs[0].save(out, save_all=True, append_images=imgs[1:], duration=durations, loop=0, optimize=True) + print(len(imgs), "frames", imgs[0].size, os.path.getsize(out) // 1024, "KB") + + +if __name__ == "__main__": + main() diff --git a/.claude/docs/captures.md b/.claude/docs/captures.md new file mode 100644 index 0000000000..edc290afa9 --- /dev/null +++ b/.claude/docs/captures.md @@ -0,0 +1,83 @@ +# UI Captures + +How screenshots and GIFs of the Hopsworks app are taken for the docs. +Diagrams are a different species, see the Diagrams section of `design-system.md`; this file is only about pixels of the real product. +Read it before replacing a screenshot, and follow `parity-review.md` for which pages still carry old-UI captures. + +## Where the pixels come from + +A dev cluster running the target release, never a mock-up and never an older release than the docs version. +The demo project should look like a real project (a `fraud_detection` style project with feature groups, a deployment, an app, a couple of jobs) so that lists are not empty and names read as real. +When the state a page describes does not exist, fake it on the cluster or in the browser rather than drawing it: create the object through the SDK or the REST API, stub a response with `agent-browser network route`, or write rows straight into the metadata database. +Leave harmless fixtures in place for the next agent and write them down in memory; revert anything that changes cluster behaviour (admin variables, auth toggles, Helm values). + +## Browser session + +Use `agent-browser` with a dedicated session and profile directory, headed, ignoring the self-signed certificate: + +```bash +agent-browser --session hopsdocs --profile "$SCRATCH/chrome-profile" --headed --ignore-https-errors open https:/// +agent-browser --session hopsdocs set viewport 1440 900 2 +``` + +Never capture from the user's personal Chrome profile: its active tab moves under you. +The viewport is 1440 css wide at device scale 2, so every capture is 2x; raise the height (1440 by 1500) when a card must fit in one shot. +A restart of the app (Helm, Payara) logs the session out; ask the user to log in again in the headed window rather than storing credentials. + +Read the page with `eval`, click with native `click` on an element you gave an id to in a prior `eval`, and use `find role button click --name "..."` for primary buttons that ignore untrusted clicks (the wizard "Next" buttons do). +Radix radio groups switch on a click of their `label`, or with arrow keys after focusing a radio. +Custom dropdowns: click the combobox input, then click the `[role=option]`. + +## What a capture shows + +Scope the capture to the panel the prose talks about, with some UI around it so the reader sees it is inside the app: never a full app shell, never a bare widget on white. +Rules that have held on every page so far: + +- A form or card: the card from its header to the last relevant block, plus about 3 percent margin on each side; clamp the top to the sticky header's bottom edge so the header never bleeds in. +- A row range inside a long form (a settings block): cut at the midpoint of the gap to the neighbouring rows so no neighbour is sliced. +- A modal: the dialog with an 18 percent margin so the blurred page behind it shows it is a modal. +- A list: the toolbar (primary button and filter) and the table, nothing else. +- Blur the focused control before shooting (`document.activeElement.blur()`), or the caret and focus ring end up in the docs. +- Fill placeholders with values that read as real (`transactions`, `fraud_source`, a cursor field, a job name), never `test` or `asdf`. + +Measure the rectangle in the page, take a viewport screenshot, and crop: + +```bash +R=$(agent-browser --session hopsdocs eval '(()=>{const e=document.querySelector("#the-card");e.scrollIntoView({block:"start"});window.scrollBy(0,-60);const r=e.getBoundingClientRect();return JSON.stringify({x:r.x,y:r.y,w:r.width,h:r.height,iw:innerWidth})})()' | tail -1) +R=${R#\"}; R=${R%\"}; R=${R//\\/} +agent-browser --session hopsdocs screenshot /tmp/vp.png +python3 .claude/docs/capture_crop.py /tmp/vp.png docs/assets/images/
/.png "$R" --margin 0.03 --top-min 72 +``` + +`capture_crop.py` derives the device scale from the rectangle's `iw`, so it works for any viewport. +The rectangle is measured after scrolling, because element screenshots lose their target when React re-renders. + +## GIFs + +A GIF is for a sequence the reader would otherwise have to imagine (a server starting, a job moving through states). +Record one viewport screenshot per state and, next to it, the rectangle of the card in that frame (the card moves when a sidebar collapses), then assemble: + +```bash +python3 .claude/docs/capture_gif.py "$FRAMES_DIR" docs/assets/images/
/.gif 0.05 +``` + +Frames are cropped around the largest card rectangle, halved to 1x and quantised, so a five-frame GIF stays under a few hundred kilobytes. +The first frame holds longer, the last one longest. + +## Naming and placement + +Images live in `docs/assets/images/
/` mirroring the page (`guides/fs/feature_group/`, `guides/jobs/`, `admin/oauth2/`). +Keep the existing file name when replacing an old capture so no page reference changes; delete captures you cannot redo rather than leaving the old UI in, and drop their figure from the page. +Alt text says what the reader is looking at, one sentence, no "screenshot of". + +## Faking states, worked examples + +- A feature the cluster does not enable: stub the availability check in the browser, `agent-browser network route "**/isAvailable*" --body '{"enabled":true}'`, then create the objects through the API. +- Feature monitoring results: register synthetic statistics per commit window through the SDK so the chart has distinct points. +- A data source that browses tables: the built-in HopsFS and JDBC sources never enable "Next: Select Tables"; create a `SQL` source against the cluster's own MySQL (`mysqld.hopsworks.svc.cluster.local`) with a dedicated read-only user and a small database of realistic tables. +- Session-capacity badges, alerts, admin panels: the memory file for the dev cluster lists the switches; check it before searching. + +## Before you are done + +Open the page on the served site and look at the capture in the column: if the text is not readable at the docs width, the scope was too wide, not the resolution too low. +Tick the page in `parity-review.md` with a note on what was redone. diff --git a/.claude/docs/caveats/diagram-inside-content-tabs.md b/.claude/docs/caveats/diagram-inside-content-tabs.md new file mode 100644 index 0000000000..a2f6baef2f --- /dev/null +++ b/.claude/docs/caveats/diagram-inside-content-tabs.md @@ -0,0 +1,5 @@ +# Diagram fragments inside content tabs + +A `--8<--` diagram include placed inside a `=== "Tab"` block is re-parsed as Markdown by the tabbed extension, unlike a top-level include which passes through as one raw HTML block. Two things break: a blank line inside the fragment ends the HTML block, so the SVG's inner tags render as paragraph text; and a `$` inside the scene JSON (`$label`, `$ms`) is picked up by the arithmatex math extension, which injects a `` into the script and the scene fails to parse (no toggle, no step bar). + +Write tabbed fragments with no blank lines, and spell the scene's dollar keys as JSON unicode escapes, `"\u0024label"` and `"\u0024ms"`, which decode to the same keys. The flywheel figures on the AI Systems page are the reference. Top-level includes need neither. diff --git a/.claude/docs/caveats/linked-tabs-text-transform.md b/.claude/docs/caveats/linked-tabs-text-transform.md new file mode 100644 index 0000000000..a41de25ff4 --- /dev/null +++ b/.claude/docs/caveats/linked-tabs-text-transform.md @@ -0,0 +1,5 @@ +# Linked tabs and CSS text-transform + +Material links content tabs (`content.tabs.link`) by comparing label text with `innerText`. `innerText` carries CSS text transforms on rendered elements but not on hidden ones, so a label uppercased by CSS reads "PYTHON" where it is visible and "Python" in every hidden set, and a switch made on the visible set never reaches the others (only labels that are already uppercase, like "CLI", keep working). The home stepper hit this: switching on step 3 left steps 1 and 2 on the old tab. + +Never put `text-transform` on a linked tab label. Write the label text as it should display and style the rest (font, weight, size). diff --git a/.claude/docs/caveats/tabs-inside-raw-html.md b/.claude/docs/caveats/tabs-inside-raw-html.md new file mode 100644 index 0000000000..e3323ce8d9 --- /dev/null +++ b/.claude/docs/caveats/tabs-inside-raw-html.md @@ -0,0 +1,5 @@ +# Content tabs inside raw HTML blocks + +A `=== "Tab"` set placed in a `
` that sits inside an outer raw HTML block without its own `markdown` attribute renders as literal text: md_in_html only parses nested `markdown` divs when the outermost raw block carries the attribute too. Code fences still render there because superfences is a preprocessor, so the breakage is easy to miss (the home stepper looked fine until tabs went in). + +Put `markdown` on the outermost wrapper as well (`
`); raw children without the attribute, such as the stepper's button rail, pass through untouched. diff --git a/.claude/docs/caveats/zoom-overlay-clone-scoping.md b/.claude/docs/caveats/zoom-overlay-clone-scoping.md new file mode 100644 index 0000000000..0c1b023fb4 --- /dev/null +++ b/.claude/docs/caveats/zoom-overlay-clone-scoping.md @@ -0,0 +1,7 @@ +# Zoom overlay clones escape .md-typeset scoping + +The diagram zoom overlay (`diagram-zoom.js`) clones the whole figure into `document.body`, outside `.md-typeset`. +Any CSS scoped under `.md-typeset` (design tokens especially) stops matching the clone, and a failed `var()` in an SVG `fill` computes to black, so the zoomed diagram renders as black boxes while the inline one looks fine. + +Scope diagram tokens and component rules to the figure class alone (`.hops-viz`, `.hops-diagram`), never through `.md-typeset`, and pin theme-dependent values inside `.hops-zoom-stage` because the overlay panel is always dark whatever the page scheme. +Animated figures get restarted on the clone by `hops-viz.js` listening for the `hops-zoom-open` event; keep that event dispatch when touching `diagram-zoom.js`. diff --git a/.claude/docs/content.md b/.claude/docs/content.md index 9252b3477d..899aaaa284 100644 --- a/.claude/docs/content.md +++ b/.claude/docs/content.md @@ -28,3 +28,24 @@ Avoid relative file paths as links (e.g. `../other.md`) — they break after mik ## Assets Images go in `docs/assets/images/
/` matching the section of the content that uses them. + +## API reference box + +A guide ends its code walkthrough (or each walkthrough section, when a page has several) with an `api` admonition, never a bare `### API Reference` heading with loose links. +Rows are the mkdocstrings symbol badge plus the autoref, exactly what the reader lands on in the API section: entry-point methods first, then each class with the methods the guide called nested under it, then any external doc with a muted `docs` badge. +No prose per row. +The last line is the single CTA into the Python API, with the `../` depth matching the page URL. + +```markdown +!!! api "API reference" + + - [`Project.get_kafka_api`][hopsworks_common.project.Project.get_kafka_api] + - [`KafkaApi`][hopsworks_common.core.kafka_api.KafkaApi] + - [`create_topic`][hopsworks_common.core.kafka_api.KafkaApi.create_topic] + - [Kafka docs](https://kafka.apache.org/documentation/) + + Browse the full Python API :material-arrow-right: +``` + +Badges: `class`, `method`, `function`, `attribute`, `docs`. +The `python-api/` root is not a page; link to `python-api/hopsworks/`. diff --git a/.claude/docs/design-system.md b/.claude/docs/design-system.md new file mode 100644 index 0000000000..5e60830ce8 --- /dev/null +++ b/.claude/docs/design-system.md @@ -0,0 +1,248 @@ +# Design System + +The visual language of docs.hopsworks.ai. +Read this before changing anything visual (CSS, nav, logo, diagrams) so the site stays one coherent system. + +## Principle + +Match the Hopsworks product app, not a generic docs theme. +The reference is `hopsworks-front` (Quartz design system, `tailwind-quartz`): flat, restrained, grid-aligned, brand green confined to the logo and small accents. +When in doubt, open the app and copy its treatment rather than inventing one. + +Two hard lessons already learned, do not repeat them: + +- Do not invent per-section nav icons. The docs navigate by content type (Concepts, Guides, API), the app navigates by entity (Feature Group, Model, Deployment). There is no icon mapping between them, so any guessed glyph reads as foreign. The app's visual language is the rail plus the green active pill plus the mark plus the typography, not a glyph per category. +- The "same visual language" is achieved with structure and color, not decoration. + +## Where the design lives + +| Concern | File | Notes | +| ------- | ---- | ----- | +| Tokens + all component styling | `docs/css/custom.css` | Single stylesheet. Tokens at the top, components below. | +| Nav collapse toggle | `docs/js/nav-collapse.js` | Header button, hides the sidebar, widens content. | +| Drill-in navigation | `docs/js/drill-nav.js` | Shows the level you are on plus the level directly above it ("yours and above"); shallower ancestors live in the breadcrumb. | +| Diagram zoom | `docs/js/diagram-zoom.js` | Corner handle + full-screen overlay for `.hops-diagram` and all content images (wrapped in `.hops-img-zoom` at runtime; inline images under 200px are left alone). Content images also carry a 1px `--hops-border-strong` border via CSS. | +| Animated diagrams | `docs/js/hops-viz.js` | Timeline stepper for the hops-viz kit (see the Diagrams section). | +| Diagram edge router + paint order | `docs/js/diagram-edges.js` | Routes declared edges (`data-from`/`data-to`) into a path, then lifts every top-level `.viz-edge` to the end of its `` so arrow + knob paint above the nodes. The router reserves a straight run-in (`RUN_IN`, in step with the checker) into the head so the arrow docks square (M1); tight gaps shrink it and the curve takes the detour. | +| Code language labels | `docs/js/code-lang.js` | Language tag on code blocks. | +| External links | `docs/js/external-links.js` | Off-site links (nav, header, content) get `target="_blank"` + `rel="noopener"`; same-host links stay in place. | +| Theme features + assets wiring | `mkdocs.yml` | `theme.features`, `extra_javascript`, `extra_css`. | + +## Color tokens + +One palette, defined once for light and once for dark (`[data-md-color-scheme="slate"]`), in `docs/css/custom.css`. +Never hardcode a hex in a rule. Use a token so light and dark both track. + +| Token | Light | Dark | Use | +| ----- | ----- | ---- | --- | +| `--hops-accent` | `#21b182` | `#1eb182` | Non-text accents: logo tint, active markers, focus rings. | +| `--hops-accent-text` | `#0e8a63` | `#3ccd9f` | Links and active nav text (AA contrast). | +| `--hops-surface` | `#f5f5f5` | dark grey | Raised fills (search field, code inline). | +| `--hops-border` | `#e2e2e2` | white 9% | 1px separators. | +| `--hops-border-strong` | `#cbcbcb` | white 18% | Card and hover borders. | +| `--hops-tint` | green 8% | green 16% | Active-nav wash behind the pill. | +| `--hops-nav-fg` | `#4b5563` | fg--light | Nav item at rest. | +| `--hops-sidebar-bg` | `#f6f7f9` | near-black | The nav panel fill. | + +Brand green is an accent, not a fill. Do not paint bands or large surfaces green. + +## Logo + +`docs/assets/images/hops-mark-green.png`, the green hop mark alone (the wordmark is the header title text). +Size it `height: 1.5rem; width: auto`. Never force a square: the mark is 142x150, a fixed width/height compresses it. + +## Header + +Flat, near-white, no shadow. The only chrome is a 1px bottom border (`--hops-border`). +Header icons and the repo link ride a muted foreground so the logo leads. + +## Left navigation + +The rail is the spine of the site. Rules, in order of importance: + +- It is a text rail, no per-section icons (see the lesson above). +- The active item is a single green pill (`--hops-tint` wash, `--hops-accent-text` text), never two split boxes; the pill is on the `.md-nav__container`. +- Deep trees (up to ~5 levels, e.g. `concepts/fs/feature_group/...`) are handled by showing two adjacent levels at a time, not by exposing the whole tree. Collapsing is about depth, never about hiding siblings: items at the current level are always all shown, and so are its parent's siblings, so a page never looks like the only thing under its section. + - `navigation.indexes`: every section has an Overview/index page acting as a hub. + - `navigation.prune`: only the active branch is rendered. + - `navigation.path`: breadcrumbs above the H1 carry the hierarchy above the current level. + - `drill-nav.js`: the rail shows two adjacent levels anchored on the active page. The indented level (with a guide rail) is the level the page lives on: the page's own siblings for a leaf, or the section's children for a section index page. The flat level above it is that section's siblings, so you always see the page's neighbours and the section it hangs from. Everything shallower than the flat level collapses into the breadcrumb and the up-header; everything deeper than the indented level stays hidden. The up-header names the section above the flat level and walks up to it. +- Collapse toggle (`nav-collapse.js`): a header button hides the whole sidebar and lets the content reclaim the width. It is a plain show/hide, not an icon rail. Desktop only; mobile uses the drawer. State persists in localStorage. +- Mobile drawer (below 76.25em): Material slides one level at a time, so `drill-nav.js` steps aside there (it only runs on desktop and re-runs on breakpoint change). The drawer rides above the header (`z-index` 1050), its title band is a compact 2.8rem strip on `--hops-sidebar-bg` with the back arrow or the logo on the left and a hairline below, the repo band sits on `--hops-surface`, and rows use 0.75rem type. Never hide the logo or sink the sidebar under the header for small screens; that was the old sub-480px block and it broke the drawer. +- The sidebar is its own panel (`--hops-sidebar-bg`). The panel fill and the right divider are painted by `.md-sidebar--primary::before` (full-bleed, spanning past the header) so the divider is flush with the header, not notched 30px below it. Do not put the divider border back on the `.md-sidebar--primary` box. + The box is `height: auto` so it wraps the scrollwrap, whose height Material's JS sets to the sticky window and shrinks above the footer; never force the box to `100vh`, that pushed the nav under the header at the page bottom and left a dead grey gap above the footer. + +## Search + +Header search (not sidebar). Bordered pill on `--hops-surface`. +The magnifier icon inherits the header's white by default and vanishes on the light field; it is forced to the muted foreground in `.md-header .md-search__form .md-search__icon`. Keep that override. +A "Docs | API" prefix is attached to the left of the search field, sharing its border, with the magnifier and text shifted right (`docs/js/search-scope.js`), so the scope is there before anything is typed; below the sidebar breakpoint it moves to the right of the text because Material puts the back arrow on the left there. API scope searches the Python API only and lists symbols; Docs is Material's full search untouched. The section you are in sets the initial scope, a click overrides it and the choice persists. +Material's own list renders page hits lazily on scroll and cannot be filtered without losing hits, so the API scope runs Material's search worker a second time, created on first use with the same index, and renders a flat list of symbols (title, dotted path) in Material's result markup while the prose list is hidden. +The search separator in `mkdocs.yml` splits on dots, underscores and camel case, so "feature vector" reaches `get_feature_vector` and `FeatureView`. + +## Home and landing UX + +Applies to the home (`docs/index.md`) and to section landing pages (the `index.md` a section drills into). +Not to the body of a doc page, which is prose and follows `content.md`. +Five rules, in order: + +- Never overwhelm. + One primary action per section, the rest visually in retreat. + At most about three choices of equal weight side by side; past that, rank them or fold them away. + Density decreases down the page: the top breathes, the tail may be a dense index. +- Two clicks, max, to what matters. + The important destinations are named and fixed: start or install, concepts, deployment options, API reference. + Each stays reachable in two clicks or fewer from the home. + Audit this list on every home or nav change; pruning links elsewhere is fine as long as none of these five moves past two clicks. +- Drive to usage, not reading. + The dominant action of the home is to start building: real, copyable code that runs. + Every major section ends on a concrete next step, never a dead end. +- Clear visual hierarchy. + Rhythm carries meaning: alternate the formats down the page (hero band, primary block, cards, compact link index, muted colophon). + Never stack more than one grid of same-weight cards in a row; that is the flatness that reads as stale. + One level of green accent per view; green pulls the eye to the action, not to decoration. +- Clean, SOTA. + Stay in Quartz: flat, no shadow, grid-aligned, near-white. No gadgetry. + Server-rendered or static; content never depends on JS to exist. + +Section landings (`user_guides/index.md`, `user_guides/projects/index.md`, `user_guides/compute/index.md`, `user_guides/analytics/index.md`, `user_guides/fs/index.md`, `user_guides/mlops/index.md`, the feature group and feature view indexes, `setup_installation/index.md`) all share one three-beat shape, in this order: + +- Two sentences of intro, what the section owns and in which order the guides go. +- One `grid cards hops-start` card: the single most common first task, with a runnable snippet and two or three links. It is the only tinted surface on the page. +- A `hops-task-index`: two columns of intent groups (`hops-task-group`, caption via `hops-role-cap` + `hops-role-ico`), three to five entries per group, one entry per topic with a one-line "what you do here". The deep pages stay in the rail; a landing that lists every page is a laundry list, not a landing. + +The home is the worked example: hero, then the three-step runnable stepper (install and connect, write, read; linked Python and CLI tabs; the step rail runs horizontally above a full-width code panel so no line is cropped), a two-card row for where Hopsworks runs (SaaS, your cloud or on-prem), the FTI diagram, a borderless role index (`hops-role-index`), the ops task table, and a muted `hops-colophon` footer. +Six sections, six different shapes; that is the anti-stale pattern, keep it. +The install lines live inside step 1, not in a separate card: one hot path from empty shell to feature vector. + +## Diagrams + +Three kinds, do not mix them up: + +- Navigational / architecture charts: clickable inline SVG built on the shared `.hops-diagram` CSS kit. Use `currentColor` plus tinted brand fills so they adapt to light/dark, and version-safe relative `href`s for the clickable nodes. +- Illustrations only: mermaid. Mermaid's `click` directives break rendering under Material's strict `securityLevel`, so mermaid is never used for clickable navigation. + +`diagram-zoom.js` adds a corner handle and full-screen overlay to any `.hops-diagram`. + +### Node families and icon+label placement (locked) + +The reference is the home FTI diagram (`diagrams/index/one-architecture-three-pipelines.html`) and `concepts/projects/governance`. +Two node families, do not blur them: + +- DATA (stores, endpoints, tables): `viz-kv-frame` + a rounded-top `viz-kv-header` band, title left, meta right, then `viz-kv-entry` rows or a subtitle. + The band path drops `v20` from the corner arc, so a band is 26 tall from the frame top; the title baseline (and the meta sharing it) sits at frame top + 19, the band's optical centre, never in its top half. The checker enforces this. + A table of linked items (a project, a data-source column) uses `viz-field` rows with `viz-row-sep` separators, each row a `viz-link`. + A key that links two tables (a foreign key row and the primary key row it joins) is tinted with `viz-key-row` in one tone per key, and the edge between them carries the same `data-tone`, so one key can be followed across tables (star and snowflake figures on the query guide). +- COMPUTE (pipelines, processes): a neutral `viz-node` box with a `viz-pill` tab straddling the top edge, `data-tone="accent"`, title + subtitle. + +In a state figure the steady states are full boxes (a toned one carries `data-state="active"` so its fill tints) and a transient step the system only passes through (starting, stopping) is a `viz-ghost` node: dashed, 40 tall instead of 52, title only, so the boxes are not all the same weight (deployment status figure on the deployment state guide). + +An icon and its label are a nested unit with two levels of rule: + +- Inside the unit: single-line label, the icon is vertically centered on the line. + Multi-line label, the text is left-anchored so it sits cleanly against the icon, and the icon is vertically centered on the middle of the block. +- The unit as a whole: centered in its component, horizontally and vertically. + Table rows are the exception, they stay left-anchored to the frame like a list, not centered. + +A zone marking the Hopsworks boundary carries `data-tone="accent"` (faint green wash, green label); other zones stay neutral. One Hopsworks per figure: two projects or registries live inside one zone, never in two. + +Edges dock on the node border with a knob at the source (`marker-start`) and an arrow at the target (`marker-end`); a feedback or automation link is dashed. +Author edges as top-level `` children: `diagram-edges.js` lifts every `.viz-edge` to the end of the `` at load, so the arrow and knob paint above the node border instead of behind it (SVG paint order is document order, and nodes are authored after edges). +Node icons render at `scale(0.7)`, row icons at `scale(0.6)`; stroke inherits the node tone. +Normalize every `viewBox` origin to `0 0`. + +### Edge and layout mechanics (enforced) + +Four mechanics make every diagram behave the same way, whether drawn now or later. +They are geometric law, not taste, so they live in `viz_overlap_check.py` as hard fails, not in prose that drifts. +SVG has no layout engine, so the checker is the enforcement library: there is no runtime force and no build-time relaxer, the rules are a gate the static SVG must pass. +Distances follow an 8-unit spacing grid, not the arrowhead: the marker owns its pixels, the grid owns spacing, so resizing a marker never re-litigates layout. + +- M1, earned approach. + An arrow's straight run-in to its head must be at least 16u (2 grid units), enough for the head to breathe. + Where the direct gap is shorter, the edge curves out and back to earn the distance rather than stubbing straight across; a curved approach is earned by definition. +- M2, force field. + No two separate block borders sit closer than 24u (3 grid units). + Nested and contained blocks are exempt (a node inside a zone, a code box inside a node), and so is any pair an edge deliberately connects, since that gap is the edge's run-in and M1 governs it. + 32u (4 units) is the default gutter for a new diagram: 24 is the floor and law, 32 is taste. +- M3, arrowhead at 75%. + Arrow markers are `markerWidth`/`markerHeight` 9 (down from 12), keeping `viewBox 0 0 11 11` and `refX 11` so the path just renders smaller; the source knob stays 6. +- M4, anchored connectors. + Every connector that ends in an arrow (`marker-end`) also starts on a node with a `marker-start` knob. + A connector is an edge whose start docks a block; an arrow that starts in open space is an axis or a standalone direction arrow, a different species, and carries no knob. + No connector tail floats in mid-air. + A timeline axis is authored as `viz-axis` (same stroke as an edge, arrow head, no knob): the edge router does not lift it, so windows and nodes drawn on the axis stay above the line. + +Run `python3 .claude/docs/viz_overlap_check.py [file ...]` before considering a diagram done; with no argument it checks every fragment. + +### Animated diagrams: the hops-viz kit + +A third kind, for process diagrams where the mechanism is the message (events flowing, windows closing, rows updating). +The architecture is adapted from Cursor's blog viz system; the palette and semantics are ours. +Reference example: the streaming pipeline diagram in `docs/concepts/fs/feature_group/streaming_feature_pipelines.md`. + +How it works, in three layers, all in `custom.css` + `docs/js/hops-viz.js`: + +- Tokens on `.hops-viz`: surfaces (`--viz-paper`, `--viz-line`), ink scale, mono type scale (`--viz-type-title/header/label/meta`), and a tone family. Tones are semantic actions, not decoration: `write`/`accent` (brand green), `read`/`data` (blue), `warn` (amber), `error` (red), `neutral`. Never hardcode a hex inside a diagram. +- Semantic SVG classes: `viz-label`, `viz-meta`, `viz-node` (+ header/title/subtitle), `viz-edge` (+ `data-variant="lane"`), `viz-tick`, `viz-window`, `viz-badge`, `viz-packet`, `viz-status-dot`, `viz-progress-track/fill`, `viz-kv-*` (frame/header/entry/cell/key/val), `viz-code-box` (raised code surface) + `viz-code` (monospace code text). State is carried by `data-state` (`active`, `visited`, `pending`, `offline`, `degraded`) and color by `data-tone` on any group; CSS renders both and transitions do the tweening. +- Showing a transformation, call, or computed value: render it as code, a `viz-code-box` rect (raised `--viz-code-bg` fill, hairline border) with `viz-code` text on top, in the form `func(arg) -> result`. Colour tokens with tspans: `tok-fn` (blue) for the function, `tok-str` (green) for the produced value, `tok-kw` (ink). This is the standard, do not leave code as floating text on the paper. The result reveals with the `type` op so the value is watched being computed; keep the function vocabulary consistent with the API pages (`min_max_scaler`, `standard_scaler`). +- A `viz-window` laid over an axis line (timeline figures) carries `data-solid=""` so its tint is mixed into paper rather than transparent and the line does not run through the label. +- Tone must survive the animation. An animated `viz-node[data-tone]` only carries its colour while `active`; at rest it falls back to grey. If a node's tone is meaningful at rest (a colour-coded category), pin it with an inline `style="stroke:var(--viz-tone)"` on the rect so the border keeps the tone after the scene settles. +- The driver (`hops-viz.js`): a figure with class `hops-diagram hops-viz` plus a sibling `