Engine emits Floats at Fixed3 quantisation; UI now renders them as 3-decimal
fixed-point strings without thousand separators, monospaced via var(--font-mono)
on .numeric cells, and right-aligned in tables so columns line up on the
decimal point. Integer counts render with 0 decimals and no separators;
science fractions render as 1-decimal percent (matches the engine's third
decimal of precision).
Bug fixes from #51 (umbrella #43):
- Player Status drive/weapons/shields/cargo: were tech LEVELS rendered
through formatPercent (x100) — now use formatFloat (raw level).
- Races table: same bug, same fix.
Style/UX cleanups:
- Inspector field labels lose "stockpile" word ($ / M suffix carries it).
- Coordinates drop the parentheses (just "x, y").
- Inspector + report tables unify font sizes with calculator-tab
(values 0.85rem mono, labels 0.8rem).
Files:
- new util: ui/frontend/src/lib/util/number-format.ts
- report/format.ts becomes a thin re-export to keep section imports compact
- inspector planet / ship-group / actions: drop inline formatNumber,
mark numeric <dd> with class="numeric"
- table-races (+ bug fix), table-sciences, table-ship-classes,
designer-science: drop inline formatters, switch to util, add
class="numeric" on numeric <th>/<td>
- 17 report section files: class="numeric" on numeric th/td +
scoped CSS rule for mono+right-align
- i18n en/ru: drop "stockpile" word, drop "%" from tech-level column
headers in races + player_status (the "%" was the misleading bit
from the bug)
- tests/inspector-planet + tests/table-races: update assertions to
match the new format
Verification: pnpm test (814 passed), pnpm check (0 errors/warnings),
pnpm build clean.
Refs: #51 (#43 umbrella).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-up nits on the F8-04b sidebar:
1. The bare-`lobby` resolver (lobby-screen.svelte) redirected to
`games-recruitment` unconditionally on mount. With games already
in the player's roster the sidebar then highlighted the wrong
sub-page. The resolver now awaits the lobby fan-out + account
fetch, then hands off to the same `firstVisibleGamesScreen` helper
the sidebar uses — so a fresh entry with games lands on
`active-past`, the canonical-order fallback stays `recruitment`.
2. `games-invitations` was unconditionally visible in the sidebar.
Now it follows the `active-past` rule: hidden until the
pending-invites list reports >=1. The lobby shell's auto-kick
effect treats it symmetrically — accepting / declining the last
invite moves the player to the next visible sub-page once the
fan-out has resolved.
Acceptance order in games-invitations-screen.acceptInvite was also
swapped to setMyGames-before-removeInvitation: both mutations land
in the same microtask, so the new auto-kick sees the freshly added
game in `myGames` when invitations drop to zero and routes the
player to `active-past` instead of bouncing through `recruitment`.
The visibility predicates and canonical order live in the new
`src/lib/lobby-nav.ts` pure helper, shared between the sidebar and
the resolver so they cannot disagree. Unit tests cover every
combination of (hasMyGames, hasInvitations, isPaidOrDev).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- lobby-create-screen: call lobbyData.refresh() after a successful
POST so the new game shows up in the private-games panel
immediately. The shared lobby-data store is otherwise lazy
(ensure-on-first-mount), which rendered a stale list across the
post-create navigation in the e2e suite.
- e2e tests that move between lobby sub-panels now go through
`window.__galaxyNav.go(...)` rather than clicking the sidebar
items. The mobile sidebar tucks the submenu behind a dropdown, so
testid-based clicks fail on the mobile-iphone-13 / pixel-5
viewports — the dev nav surface bypasses that UX (which has its
own coverage in `lobby-tier-gate` / future submenu specs).
- game-shell-map missing-membership test: assert
`lobby-account-name` instead of `lobby-create-button` on
drop-back-to-lobby (the button moved into the paid-only
private-games sub-panel; the identity strip is the constant lobby
chrome).
- inspector-ship-group + ship-group-send synthetic loader specs:
jump straight to the dev-only `synthetic-reports` top-level
screen via the dev nav surface before looking for the file
input (the loader moved off Overview in F8-04b).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reshape the lobby UI from a single Overview into a two-level sidebar
(games · profile · DEV synthetic-reports) with four games sub-panels
(active-past · recruitment · invitations · private-games). Move the
`create new game` button into the private-games panel, merge the
applications section into recruitment cards as status chips, and add
DEV-only synthetic-report loader as a top-level screen.
Add a paid-tier gate at backend `lobby.game.create`: free callers get
`403 forbidden` before the lobby service is invoked. The UI hides the
private-games sub-panel + create button on free tier (DEV affordances
flag overrides). Update every integration test that creates a game to
use a new `testenv.PromoteToPaid` helper; add a new
`TestLobbyFlow_FreeUserCreateGameForbidden`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR-feedback round on #60:
- Time-zone field is now a continent-grouped <select> populated from
`Intl.supportedValuesOf("timeZone")`, with the browser-detected
zone pre-selected when no value is stored. A stored zone the
runtime no longer advertises is preserved as an "Other" entry.
- Saving the profile no longer kicks the user back to the lobby:
the form stays put and shows a transient `saved` notice, cleared
on the next edit. Only `cancel` returns to the lobby.
- New `lib/account-store.svelte.ts` caches `user.account.get` for
the session; lobby + profile share it through `account.ensure()`,
so navigating Overview ⇄ Profile no longer flashes the
"loading account…" placeholder or fires a second gateway call.
Profile save writes through to the store so the shell identity
strip picks up the new display name without refetching. Cleared
on logout to prevent identity bleed between accounts.
- e2e: existing 4 cases adjusted for save-stay; added two new ones
for the timezone dropdown and identity-strip stability across
navigation.
- Docs: `ui/docs/lobby.md` updated to describe the shared cache,
the new timezone picker shape, and the save-stay behaviour.
- Wrap lobby and profile in a shared `lobby-shell.svelte` chrome:
page-list sidebar (Overview/Profile) and a top "Player-xxxx"
identity strip mirroring the project site's monospace look.
- Strip the legacy `lobby.title`, device-session-id `<code>`, and
`lobby.greeting` paragraph; the identity strip both names the user
and opens the profile editor.
- Add a top-level `profile` AppScreen with a three-field form
(`display_name`, `preferred_language`, `time_zone`) backed by a new
`src/api/account.ts` wrapper around `user.account.get`,
`user.profile.update`, and `user.settings.update`. Saving switches
the active i18n locale in-place when the new preferred language is
one the UI ships translations for.
- Update e2e fixture + auth-flow / lobby-flow specs to use the new
`lobby-account-name` testid and wait for the loaded identity before
releasing pending `SubscribeEvents` (webkit revocation race). New
`profile-screen.spec.ts` covers navigation, edit-save, and cancel.
- Sync `ui/docs/lobby.md` and `ui/docs/navigation.md` to the new
layout.
Closes#47
- Remove the `delete <ship_class_name>` button (and `deleteClass`,
`canDelete`, `.delete` CSS, `game.calculator.action.delete` i18n key)
from the calculator. Delete-class lives in the ship-classes table —
the broader rework will land under #53.
- Bombing and cargo-capacity rows now reserve a hidden lock-slot
placeholder so their value column lines up vertically with the
mass/speed/attack/defence rows (which carry a lock button).
Owner feedback round 2 on PR #61:
- Pad every read-only calculator value to three decimals: tech labels,
derived results (mass, speed, attack, defence, bombing, cargo
capacity), planet MAT, planet build-rate, modernization cost, and
the full-cargo capacity label all read as "1.000" instead of "1",
matching the goal-seek back-solved input and the report. Drops
thousands grouping so the same `fmt()` string also embeds cleanly
in the read-only `<input type="number">` cell.
- Switch label and input styling onto the existing `--font-mono`
token (right-aligned, tabular-nums) so columns line up vertically
across rows like a financial table.
- Refuse a fourth decimal as the user types in every calculator
number input (DWSC blocks, tech, MAT, custom load, lock value,
modernization target tech): the `oninput` truncates the input text
past three decimal digits and explicitly writes the truncated
value back through `bind:value`, so Svelte's later reactive flush
cannot undo the cap.
- Doc + tests follow the rule (five new vitest cases covering the
3-decimal label format, the input cap on each input class, and
the integer-padding rule for derived results).
Owner review on PR #61:
- п.9 (option B). Hide the native spinner on EVERY numeric input in
the calculator (DWSC blocks, armament, tech, planet MAT, custom
load, lock value, modernization target tech) and drive every step
through ArrowUp / ArrowDown. The column widths stay stable and the
inputs read consistently across the whole row. The ship blocks
keep the smart (0 ↔ 1) jump on ArrowUp/ArrowDown; armament steps
±1 with a JS handler instead of relying on the native spinner.
Other inputs step by their natural grain (±0.001 for tech / lock,
±0.01 for MAT / load).
- п.10. Tech-level labels (`tech-val`) and the planet MAT label
(`mat-val`) now read through the same `Ceil3` formatter as the
derived results, so plain-text numeric values share the report's
3-decimal tabular formatting. The design-area component receives
`formatNumber` as a prop; the resolved (goal-seek) cell uses the
same formatter, so the read-only computed value matches the rest
of the row.
- п.12. `computeCalculator` now validates the back-solved block
against the same DWSC rule the live validator enforces (`0` or
`≥ 1`). When the solver lands in the `(0, 1)` gap (e.g. attack
0.5 / weaponsTech 1.5 → weapons 0.333…) the lock is flagged
infeasible — the lock input flips red and the claimed block is
NOT back-solved into the invalid range, so the design preview
keeps reading the user's own typed values instead of silently
showing a sub-1 block.
- new. Selecting an existing ship class from the name datalist now
loads it immediately. `change` fires only on blur in Firefox,
which is why the previous behaviour looked delayed; switching the
load to `oninput` with an `InputEvent.inputType` check makes the
load synchronous everywhere (datalist replacement carries
`"insertReplacementText"` in Chromium / WebKit, `undefined` in
Firefox; keyboard typing always carries a typing `inputType`).
Before loading we compare the live blocks to the previously
loaded class (or to the empty defaults) and, if they differ, ask
through a `window.confirm`. On decline we revert the name field
and leave the design untouched.
Tests: calculator-tab and calc-model gain six cases (armament
step, tech/MAT formatter labels, lock infeasible on (0, 1) for
both attack→weapons and emptyMass→cargo, lock-value Arrow step,
dropdown immediate load + confirm-blocks-load + confirm-allows-load),
all 779 vitest tests green. docs/calculator-ux.md follows the new
behaviour.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- pkg/calc: DriveForSpeed treats restMass==0 as a valid ceiling-only
case (every positive drive solves it), so locking the displayed
speed of a D=1, W=A=S=C=0 ship is no longer a phantom "infeasible".
- ship-design-area: drive/weapons/shields/cargo inputs use a JS-driven
smart step on ArrowUp/ArrowDown (0↔1 jump, otherwise ±0.1) and hide
the native spinner so it cannot produce invalid (0, 1) values;
armament keeps its native step 1.
- Tech and planet MAT cells follow the same lock idiom as goal-seek
locks: open padlock (🔓) over the inherited value → click to open
an input with a closed padlock (🔒). The padlock slot is always
reserved, so the column width is stable.
- Tech overrides (design area and modernization target) are floored
at the player's current tech on this turn — a lower value is
flagged as invalid.
stabilise report-sections e2e
Owner review on PR #58:
- shrink the order-card body to 0.8rem (matching the calculator's body
text scale) so the order list reads as part of the sidebar's
density, not its own larger surface;
- shrink the delete ✕ to 0.95rem and glue it flush to the card's
top-right corner (no offset, sized to fit the corner padding-space);
- tighten the card padding to match the smaller text.
Independently — the same review asked to fix `report-sections › every
TOC anchor lands its section in view`, which had been a long-standing
e2e flake (run #366 on `development` already failed it twice before
passing on retry; my PR's run #367 simply exhausted all five retries).
The root cause is the smooth `scrollIntoView` settling slower than
Playwright's 5 s viewport wait under heavy CI load. The production
TOC already honours `prefers-reduced-motion: reduce` and swaps to an
instant scroll there; switching the Playwright config to that media
mode makes every spec deterministic without touching production code.
The order-tab row now wraps long labels (`overflow-wrap: anywhere`),
encodes status into the card background via the design-token subtle
palette (applied → success-subtle, invalid/rejected/conflict →
danger-subtle, draft/valid/submitting → warning-subtle), and exposes
a small framed `✕` delete button absolutely positioned in the
card's top-right corner — always visible, labelled by
`game.sidebar.order.command_delete` for assistive tech. The textual
status name remains in the DOM as an `.sr-only` node so screen
readers and the existing testids still observe it.
Refs #46
Clicking the current-turn row in the header turn navigator while
already viewing it routed through returnToCurrent() →
viewTurn(currentTurn), which re-fetches the live report and flips the
view through `loading`. At turn 0 the only row is the live turn, so
the dropdown always fired a pointless backend round-trip and redraw.
Guard goToTurn() against re-selecting the on-screen turn
(turn === viewedTurn): just close the popover and stop. Leaving
history is unaffected — there the viewed turn differs from the target.
Closes#45
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The two-step e-mail login now drops the cursor on each step's primary
field as it mounts — the e-mail field on load, the code field once the
e-mail step advances — via a small `use:` action. Focusing fires each
input's onfocus, which clears the readonly autofill guard, so the field
is editable straight away.
The code input now requests `autocomplete="one-time-code"` instead of
`new-password`. The latter is a password-manager hint and does not stop
Firefox saving the typed code to form history (it was offering the
previous code back in a dropdown). `one-time-code` is the semantic token
for a verification code; Firefox honours it specifically to keep the
value out of form history (Mozilla bug 1547294). The e-mail field keeps
`new-password` to fend off saved-login autofill.
Tests: new Vitest cases assert autofocus on both steps and the code
field's `one-time-code` token; a new Playwright case covers the same in
Chromium and WebKit (Safari engine). Firefox form history is owner
manual-QA — there is no Firefox project in the e2e matrix.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The map view now selects a DARK_THEME or LIGHT_THEME palette from the
resolved app theme and threads it through every primitive builder, so
the canvas, planets, ship groups, cargo routes, battle/bombing markers,
fog, reach + selection rings, pending-Send tracks, and the pick overlay
all switch with the rest of the chrome. A theme flip remounts the
renderer preserving the camera — Pixi bakes the background at init and
every primitive bakes its colour at build, so a live re-tint is not
possible on the same instance.
This also fixes the reported bug: the gear-popover trigger and the
loading overlay hardcoded a dark navy background, so in light theme the
gear was invisible (dark icon on dark chip) until hover flipped it to a
white chip. Both now use the --color-surface-overlay token and read
correctly in both themes.
The light palette mirrors the dark one role-for-role, darkened /
saturated for contrast on a light background while keeping the incoming,
battle, and bombing accents vivid. The values are a first pass meant to
be refined during the F8 manual-QA loop.
Removes the now-dead "Phase 35" references from the code and lifts the
map-recoloring prohibition from the design-system / renderer docs; the
battle scene stays a fixed-palette data-viz surface.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The app-shell migration surfaced a mobile-only e2e failure: taps on the
bottom-tab bar, the map-toggles menu, and the planet sheet were
intercepted by sibling elements despite the targets being on top.
Root cause: `.game-shell` used `min-height: 100vh`, so sub-pixel content
overflowed the viewport and made the document scrollable. On mobile that
scroll toggles the browser's dynamic toolbar, which resizes the viewport
and every `position: fixed` overlay (their sizes derive from `100vh`)
mid-gesture — defeating Playwright's actionability hit-test, and making
the real controls jittery to tap.
Pin the shell with `position: fixed; inset: 0` on the mobile breakpoint
so it leaves document flow: the document can no longer scroll, the
toolbar stays put, the viewport and overlays stay stable, and the
active-view area remains the single internal scroll region. Desktop is
unchanged (the rule is scoped to max-width: 767.98px).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Unit: repoint moved screen imports (lib/screens, lib/game), mock
$lib/app-nav (appScreen/activeView) instead of $app/navigation, drop the
removed gameId props, assert screen/view selection.
- e2e: add a dev-only window.__galaxyNav affordance; specs enter a game via
enterGame(...) instead of a /games/:id URL; URL assertions become content
assertions (the URL stays /game/); reload uses waitUntil:"commit" (shallow
routing) and mocks /rpc on game entry.
- Remove the obsolete report scroll-restore test (it relied on a SvelteKit
route Snapshot that no longer exists); update the missing-membership test
to the new lobby-redirect+toast behaviour. Fix a stale report.svelte
docstring.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- A restored game that no longer exists (cancelled/removed/revoked) drops to
the lobby with a toast instead of the in-game error state: game-state
exposes a `notFound` flag and the shell redirects via appScreen.go("lobby").
- Add a visible "return to lobby" control to the in-game header.
- Push/toast deep-links use activeView.select(...) (no URL); fix a latent
visibility-listener double-install on in-place game switches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirror the screen into browser history via SvelteKit shallow routing
(pushState/replaceState with page.state) so Back/Forward move between
screens while the URL stays at /game/. Overlays (game, lobby-create) push;
lobby/login replace. A popstate→page.state effect syncs the store back
without re-pushing (no loop); the boot stamp puts a restored overlay above
the load entry so Back falls through to lobby. In-game view switches never
touch history.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Collapse the game UI to one route (`/`): a screen dispatcher renders
login/lobby/lobby-create/game from `appScreen`/`activeView` state instead of
URL routes. Move screen components to lib/screens & lib/game; the game shell
reads the game id from `appScreen.gameId` and re-inits per-game stores via an
$effect; in-game views render from `activeView`. Flip ~23 goto/href nav sites
to store mutations; drop the `?sidebar=` URL coupling. Auth gate is now
state-based. WIP: browser-history (Back→lobby), restore-validation, the
return-to-lobby button, push deep-links, and the test migration are follow-ups
on this branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add `appScreen` + `activeView` rune singletons with a shared sessionStorage
snapshot — the in-memory source of truth that replaces URL-based screen/view
routing for the single-URL app-shell. Not wired in yet (additive).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Serve the whole stack behind one host: site at /, game UI at /game/,
gateway REST at /api + /healthz, Connect at /rpc (prefix stripped by the
edge Caddy). The built artifact is domain-agnostic — the UI talks to the
gateway same-origin via relative URLs, so the same bundle runs under any
host with no rebuild and with CORS disabled.
- Rename the Connect proto service galaxy.gateway.v1.EdgeGateway ->
edge.v1.Gateway; regenerate Go + TS; public path /rpc/edge.v1.Gateway.
- Move the game UI under base path /game (env BASE_PATH); make the
manifest, service-worker scope, WASM loader, and all navigation
base-aware via a withBase helper.
- Relative API + /rpc Connect prefix; Vite dev proxy mirrors the strip.
- Rewrite the edge Caddy (dev + prod) for path-based routing; empty CORS
allow-lists (same-origin); single host.
- New VitePress project site (site/): i18n en/ru with switcher, LaTeX
math, minimal monospace theme; built and served at /.
- dev-deploy compose/Makefile + CI (dev-deploy, prod-build, new
site-build) build and seed the site; probes hit /, /game/, /healthz.
- Sync docs (ARCHITECTURE, gateway README/openapi, dev-deploy &
local-dev READMEs, CLAUDE.md, ui/PLAN).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The planet/ship-group sheets stay mounted on desktop but are hidden by a
media query (`display: none`); the document-level tap-outside listener
fired regardless, so the first click after selecting a planet cleared the
selection — breaking every desktop inspector/select flow in CI. Guard the
handler on the sheet's computed display (`offsetParent` is unreliable for
`position: fixed`). The swipe handle is naturally inert when hidden.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- lib/error/: classify any caught error into a stable ErrorKind from the
transport signal (HTTP status / Connect Code / fetch TypeError /
navigator.onLine); map to translated error.* messages via reportError
(sticky Retry toast for retryable kinds) or errorMessageKey (inline).
Mail compose now surfaces the translated 403/error inline.
- lib/ui/view-state.svelte: shared loading/empty/error placeholder with
the right live-region role + optional action; entity tables
(races/sciences/ship-classes) migrated, rest adopt incrementally.
- map/selection-ring.ts: accent ring around the selected planet, fed into
the map buildExtras alongside the reach circles.
- lib/ui/sheet-dismiss.ts: tap-outside + drag-handle swipe-down dismissal
for the planet/ship-group bottom-sheets (hand-rolled pointer events).
Tests: error, view-state, selection-ring, sheet-dismiss (761 total).
Docs: ui/docs/error-state-ux.md (+ index); F4 marked done.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
An audit found the client already i18n-first: one hard-coded UI string
(the battle-scene aria-label, now keyed) and en/ru already share an
identical 692-key set.
- Persist the locale: i18n.setLocale writes localStorage (galaxy-locale)
and the store boots from stored > browser detection > default, so a
language switch survives reloads.
- tests/i18n-completeness.test.ts: en/ru key-set parity, non-empty
values, and locale persistence.
- Docs: ui/docs/i18n.md; mark F3 done in ui/PLAN-finalize.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With the default theme now following the OS, Playwright renders the light
theme, where the previous light `--color-danger` (#c84d4d, ~3.9:1 on a
near-white surface) failed WCAG 1.4.3 on error text — caught by the axe
scan of the science designer's empty-name error. Darken light
`--color-danger` to #c0392b (~5.5:1 on white; white-on-danger fills stay
≥5:1). Dark theme unchanged.
Light has been signed off, so the theme store's default choice is now
`system` (it was `dark` during the incremental migration). This matches
the app.html pre-paint guard, which already resolved an unset choice via
prefers-color-scheme — removing the brief boot-time mismatch where the
store re-pinned dark. Users still pin light/dark via the account-menu
picker. Updates the store default + its test and the design-system /
finalize-plan docs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add the a11y foundation and bring login, lobby, and the in-game shell to
WCAG 2.2 AA:
- Primitives: .sr-only + .skip-link (base.css), trapFocus (modal focus
trap + restore) and restoreFocus (menu focus restore) actions, the
--color-focus visible ring.
- In-game shell: skip link + focusable main landmark; WAI-ARIA sidebar
tabs (roving tabindex, arrow/Home/End, tabpanel wiring); menu Escape +
focus restore (account / view / turn-navigator / map-toggles /
bottom-tabs); mail compose as a role=dialog modal with a focus trap.
- login / lobby / lobby-create: skip link + main landmark, field labels,
role=alert / role=status live regions.
- Map canvas: aria-label naming it a visual overview, with its data
reachable by keyboard via the sidebar inspector and tables (accessible
alternative; in-canvas keyboard nav deferred).
Gates (chromium-desktop): tests/e2e/a11y-axe.spec.ts scans every
top-level view for WCAG 2.2 AA violations (zero); a11y-keyboard.spec.ts
covers the skip link, menu Escape+restore, and tab roving. Adds
@axe-core/playwright. Docs: ui/docs/a11y.md (+ index). Marks F1 and F2
done in ui/PLAN-finalize.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tokenize every remaining component <style> — calculator, order tab,
inspectors, tables, report sections, lobby, auth, mail, battle viewer,
toasts, map overlays. A scripted pass handled the unambiguous core
palette (text/bg/surface/border/accent/danger/muted), the rest were
mapped to the semantic/grey tokens by role.
Remaining colour literals are the documented exceptions only: the
battle-scene SVG data-visualisation palette (fixed dark, like the WebGL
map canvas), overlay scrims (modal / map-canvas), and directional or
deliberate drop shadows. The default theme stays dark until light
coherence is signed off across the views.
Updates ui/docs/design-system.md (migration status + exceptions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduce the shared design-token system under
ui/frontend/src/lib/theme/: tokens.css (dark default + light palette,
plus spacing/radii/typography scales), base.css global baseline
(document background, text, token focus ring, selection), and
theme.svelte.ts (system/light/dark choice, persisted to localStorage,
applied via data-theme on <html>). A pre-paint guard in app.html
resolves the theme before the app boots to avoid a flash, and the theme
picker is wired into the previously-disabled account-menu stub.
Migrate the always-visible in-game chrome to the tokens (header, account
menu, sidebar, tab-bar, bottom-tabs, shell background): dark renders as
before, light comes for free. The default stays dark during the
incremental migration; the remaining view bodies migrate in F1b.
Docs: ui/docs/design-system.md (+ index entry). Test: tests/theme.test.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- custom load capped at cargo capacity (error when exceeded); full load shows the cargo capacity; zero cargo pins load to empty and disables the toggle
- per-input red border + tooltip for every invalid value (blocks, techs, load, MAT, modernization target); no value may be negative; locking a speed is disabled when drive is zero
- display every computed number (results + goal-seek back-solved input) rounded up to 3 decimals via a shared pkg/calc Ceil3 bridged to wasm; engine keeps its own round-to-nearest util.Fixed*
- modernization total upgrade cost spans two columns (single line)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the calculator's inputs into a page-level calculatorState singleton so they survive the sidebar unmounting the tab on a tab switch (the inspector auto-opens on a planet click). ensureGame resets the design when the active game changes.
While on the calculator, a planet click no longer switches to the inspector — the calculator consumes the selection in its planet area / reach circles. Halve the reach-circle stroke width.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fuse the standalone ship-class designer (Phases 17/18) into a sidebar calculator: live mass/speed/attack/defence/bombing results, a planet build-rate readout, single-target goal-seek, a modernization-cost mode, and auto reach circles on the map for the selected planet.
pkg/calc becomes the single source for the new math (no mirroring): extract BombingPower from the engine model and the per-turn ship-production loop from controller.ProduceShip into pkg/calc (engine now delegates), and add inverse goal-seek solvers in pkg/calc/solve.go. Thin-bridge the combat, planet-build, and solver functions through ui/core/calc + ui/wasm and rebuild core.wasm.
Remove the standalone designer view/route; the ship-classes table and the view/bottom menus open the calculator via a shared request store.
Docs: rewrite ui/PLAN.md Phase 30, adjust Phase 34 (realistic forecast + CAP/COL ownership), add ui/docs/calculator-ux.md, extend calc-bridge.md, fix navigation.md; remove ui/CALCULATOR.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Phase 29 visibility fog ("visible hyperspace") froze the whole UI on
large reports in Safari while staying smooth in Firefox. Root cause: the
fog is a layered overpaint (torus mode = 9 world-sized rects + 9xN
near-world-sized opaque circles, ~270 fills for KNNTS041) and Pixi's
continuous auto-render loop re-rasterised all of it every frame, even
while idle. Safari's WebGPU backend cannot sustain that fillrate, so the
main thread/compositor starved and the entire UI froze.
Stage 1 (vector-preserving, no rasterisation):
- Stop Pixi's auto-render loop (app.stop()) and paint on demand via a
single Ticker.shared flush gated on viewport.dirty (camera) plus an
internal requestRender() from every content mutation (fog / hide-set /
extras / wrap mode / resize / pick overlay). An idle map now does zero
GPU work per frame; plain hover paints nothing.
- Remove the decelerate (drag-inertia) plugin: a released drag stops
instantly (owner request) and the viewport goes idle immediately.
- Expose RendererHandle.getRenderCount() / getMapRenderCount for
deterministic e2e assertions.
Tests: new map-toggles e2e specs (idle map does not repaint; released
drag does not coast) green on all four Playwright projects incl. WebKit.
Docs: renderer.md (render-on-demand section; fog section corrected to the
current single-fogLayer model; FPS note) and PLAN.md Phase 29 decision 8.
If Safari pan is still heavy after this, stage 2 will cut the overpaint
itself with an inverse stencil mask of the circle union (kept vector).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Phase 29 fog overlay rendered as a handful of random arc
segments instead of a clean union of holes around LOCAL planets
— Pixi v8's `Graphics.cut()` does not reliably subtract multiple
overlapping circles from a base path.
Replaced the cut-based approach with a layered overpaint: a
fog-tinted rectangle fills the world, then opaque background-
coloured circles are painted on top for every visibility circle.
The natural rendering order unions overlapping circles for free —
no geometry, no `cut()` quirks, one extra fill per circle.
Renamed the toggle from `visibilityFog` to `visibleHyperspace`
across the store, i18n strings, popover, tests, and docs. The
overlay still implements the visual "fog" effect at the renderer
level (FOG_COLOR, setVisibilityFog, getMapFog); the toggle is
named after the player-facing concept it controls — the portion
of the map that is visible (intelligence/scan coverage) — rather
than the obscured part.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous logic re-mounted the renderer whenever
`store.wrapMode` flipped, because the `sameSnapshot` gate
included `handle.getMode() === mode`. Pixi 8 does not reliably
re-initialise an `Application` on the same canvas — the symptom
showed up as the chromium tab silently closing during the
Phase 29 wrap-mode e2e ("Target page, context or browser has
been closed").
The renderer already exposes an in-place `setMode` that swaps
the wrap-clamp / torus-copy visibility synchronously while
preserving the camera; the playground-map.spec.ts wrap toggle
has been driving it for several phases without issue. Drop
mode from the snapshot gate and route the change through
`handle.setMode(mode)` instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Run #217 surfaced three independent bugs that survived the first
fixup pass:
1. `visibleHighBitCount` masked the id with `(prim.id >>> 0) & 0xf…`,
but JS bitwise AND always returns a signed int32 — the mask had
to be re-converted with `>>> 0` AFTER the AND, not before. Result
was always 0 on the previous run, masking the next two bugs by
making the persistence test's high-bit-count assertions a
tautology.
2. `applyVisibilityState` was wrapped in `untrack`, so the
`toggles.X` reads inside `computeHiddenIds` / `computeFogCircles`
never landed in the effect's dependency set — toggling fog or any
marker / group / kind flag did not re-run the effect, so the
renderer never received the new hide / fog input. Explicit
`void toggles.X` reads now live at the top of the effect so every
key is tracked synchronously.
3. The wrap-mode radios fired on `onchange`, which Svelte 5
suppresses on a re-activation of an already-checked input — the
Playwright `.click()` flake on the second wrap test reflected the
missed event. Switched to `onclick` and short-circuited when the
target mode is already active.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three independent bugs in `tests/e2e/map-toggles.spec.ts` made the
fresh-Phase-29 suite red on CI #216:
1. `visiblePlanets` filtered on `p.id < 1_000_000`, which JS interprets
in signed space — high-bit-prefix primitives (cargo route 0x80…,
battle 0xa0…, bombing 0xc0…) are stored as negative Numbers and
leaked into the planet list. Filter switched to a `0 < id < 1e7`
window that matches the engine planet-number range exactly.
2. The `visibleHighBitCount` helper now ToUint32-converts the id
before masking so the bitmask comparison works regardless of
whether the id is stored as positive or negative.
3. The fog and wrap-mode tests read the renderer state synchronously
after the click — the Svelte effect re-runs asynchronously, so the
tests saw stale state. Both now `waitForFunction` on the canonical
"settled" signal: empty fog circles for the fog flip, and a new
`getMapMode()` debug accessor for the wrap-mode remount.
Renderer side: registers a `MapModeProvider` next to the existing
camera / fog providers and exposes `getMapMode()` through the debug
surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the gear-icon popover on the map view with per-game persistence
of every category toggle plus the wrap-mode radio. Hide-by-id and
visibility-fog facilities land on the renderer so every flip applies
within one frame without a Pixi remount; the wrap-mode toggle keeps
its existing remount + camera-preserve path. A new server-side turn
force-resets every flag to defaults so a hidden category never makes
the player miss the next turn's news.
Also fixes the FligthDistance → FlightDistance typo in pkg/calc/race.go
(plus the single Go caller); the TS side keeps duplicating the formula
until a race-level WASM bridge lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Phase 28 ConnectRPC migration of the battle viewer added a
guard in `lib/active-view/battle.svelte` that waits for the
surrounding layout to publish a `GalaxyClient` before issuing the
fetch. The in-game shell layout deliberately skips
`galaxyClient.set(...)` on the synthetic branch (gateway is not
reachable in synthetic mode), so for any battle opened from a
synthetic-report game the viewer sat on "loading battle…"
forever — `fetchBattle` was never called, so the synthetic-fixture
short-circuit it carries was unreachable.
Let the guard skip synthetic ids: `fetchBattle` already resolves
those through `lookupSyntheticBattle` and never touches the
client, so its signature widens to `GalaxyClient | null` and the
synthetic path passes `null`. The live path still waits for the
handle as before; a `null` client on the live path now fails
fast with a transport-level `BattleFetchError` instead of silently
sitting on `loading`.
Tests:
- Existing "loading placeholder" smoke now uses a non-synthetic
game id so it keeps asserting the live-path wait.
- Two new cases pin the synthetic behaviour: missing fixture →
`battle-not-found`; registered fixture → `BattleViewer` mounts.
Docs:
- `docs/FUNCTIONAL.md` §6.5 still described the pre-Phase-28
raw REST path. Updated to the signed ConnectRPC command and
noted the synthetic short-circuit. Russian mirror updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues surfaced once the long-lived dev environment finally
reached the diplomail view:
1. `/sent` returns one row per recipient for broadcast and admin
fan-outs (so the admin tooling can render the materialised
audience). The list pane fed all rows into the stand-alone
bucket, so the `{#each entries as e (entryKey(e))}` key in
`thread-list.svelte` collapsed to the same `standalone:${id}`
for every recipient and Svelte 5 aborted the render with
`each_key_duplicate`. Dedupe stand-alones by `message_id` in
`buildEntries`.
2. The compose dialog exposed an `admin` kind toggle gated on
"owner of game". That was a Phase 28 plan decision, but admin
compose is an operator tool (server admin), not an in-game
action — every game owner should not be able to broadcast
admin notifications. Drop the admin option, the audience
sub-toggles, and the admin path through `submit`. The
`MailStore.composeAdmin` wrapper and the backend RPC stay so
the future admin UI can call them.
Vitest covers the fan-out dedup with three rows sharing one
`message_id` collapsing to a single stand-alone entry.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Phase 27 BattleViewer was the last UI surface still issuing raw
fetch() against the backend REST contract (`/api/v1/user/games/...
/battles/...`). The dev-deploy gateway never proxied that path, so
the viewer worked only in tools/local-dev/. Move it onto the signed
ConnectRPC channel every other authenticated surface already uses.
Wire pieces:
- FBS GameBattleRequest in pkg/schema/fbs/battle.fbs, regenerated
Go + TS bindings.
- MessageTypeUserGamesBattle constant + GameBattleRequest struct in
pkg/model/report/messages.go.
- pkg/transcoder/battle.go gains GameBattleRequestToPayload and
PayloadToGameBattleRequest helpers.
- gateway games_commands.go switches on the new message type and
GETs /api/v1/user/games/{id}/battles/{turn}/{battle_id}; the JSON
response is re-encoded as a FlatBuffers BattleReport before being
returned. 404 from backend surfaces as the canonical `not_found`
gateway error.
- ui/frontend/src/api/battle-fetch.ts now builds the FBS request,
calls GalaxyClient.executeCommand, and decodes the FBS response
into the existing UI shape (Record<string,string> race/ship maps,
string-form UUID). BattleFetchError carries an HTTP-style status
derived from the result code so the active-view's not_found branch
keeps working.
- battle.svelte pulls the GalaxyClient from the in-game shell
context. While the layout's boot Promise.all is in flight the
effect stays in `loading` until the client handle becomes
non-null.
- ui/Makefile FBS_INPUTS gains battle.fbs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Step 7 — header view-menu badge.
`view-menu.svelte` reads `mailStore.unreadCount` and renders an
inline pill next to the "diplomatic mail" entry whenever the
counter is non-zero. The badge styling matches the per-row dot in
`thread-list.svelte` so the two surfaces feel consistent.
Step 8 — push event handler + MailStore init in the in-game layout.
`routes/games/[id]/+layout.svelte`:
- registers a `diplomail.message.received` handler alongside the
existing `game.turn.ready` / `game.paused` ones, parses the
signed payload, calls `mailStore.applyPushEvent` to refresh the
inbox for the matching game, and raises a toast with a "view"
deep-link that navigates to `/games/:id/mail`;
- adds `mailStore.init({ client, cache, gameId })` to the boot
`Promise.all` so the inbox + sent lists are warm by the time the
view mounts, and the badge counter is populated before any user
interaction;
- disposes the new subscription in the `onDestroy` block so a game
switch does not leak handlers across navigations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 6 — mail active view + subcomponents.
- `lib/active-view/mail.svelte` replaces the Phase 10 stub with the
list / detail layout: two-pane on desktop, one-pane stack on
mobile (CSS media query, no separate route).
- `lib/active-view/mail/thread-list.svelte` renders per-race
threads collapsed to their last message plus stand-alone
system / admin / outgoing-broadcast items, with unread badges.
- `lib/active-view/mail/thread-pane.svelte` is the chat-style
transcript for one race; bodies render through `textContent`,
per-message Show original / translation toggles flip the
rendering when a translated body is present, and a persistent
reply box at the bottom calls `mailStore.composePersonal`.
- `lib/active-view/mail/system-item-pane.svelte` renders one
stand-alone item read-only with the same translation toggle.
- `lib/active-view/mail/compose.svelte` is the compose dialog:
recipient race picker fed from `report.races[]`, kind toggle
(personal / broadcast / admin), admin sub-toggle for target
user / all and recipient-scope picker. Server-side enforces
paid-tier and owner gating; the UI surfaces 403 inline.
- `lib/active-view/mail/system-titles.ts` keeps the keyword →
i18n-title mapping for lifecycle-hook system mail so both the
list and the detail pane pick the same canonical title.
Step 9 — i18n strings (en + ru).
`game.mail.*`, `game.view.mail.badge`, `game.events.mail_new.*`,
`game.mail.system.*` keys added in lockstep across both locales
covering compose labels / validation copy / per-system titles /
translation toggle / reply / delete affordances.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `src/lib/mail-store.svelte.ts` — the reactive store that
coordinates the in-game mail view. Responsibilities:
- holds the inbox and sent listings for the current game and fires
the initial parallel fetch (`fetchInbox` + `fetchSent`) on
`setGame`;
- exposes a `entries` derived rune that builds the unified list
pane: per-race threads merged from incoming + outgoing personal
messages, plus stand-alone items for system / admin / own
paid-tier broadcasts. Thread messages are sorted oldest → newest
for chat-style rendering; the list itself sorts newest-first by
the most-recent entry timestamp;
- derives `unreadCount` from `readAt === null` rows for the header
view-menu badge;
- imperative `markRead` / `softDelete` actions with optimistic
state flips and roll-back on RPC failure;
- compose actions for personal / paid-tier broadcast / owner-admin
sends;
- `applyPushEvent(gameId)` hook called by the layout when a
`diplomail.message.received` push frame arrives; refetches the
inbox without trusting the preview payload;
- persists the most recent message id under
`cache.diplomail/${gameId}/last-seen` so a returning session can
pre-paint the badge without a network round-trip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Legacy reports list the same `(race, className)` pair across several
roster rows; the engine likewise creates one ShipGroup per arrival.
Both the legacy parser and `TransformBattle` were keyed on shipClass
without summing — only the last row / group's counts survived, so a
protocol's destroy count appeared to exceed the recorded initial
roster. The UI worked around this with phantom-frame logic.
Both parser and engine now SUM `Number`/`NumberLeft` across rows /
groups sharing the same class; the phantom-frame workaround is gone.
KNNTS041 turn 41 planet #7 reconciles: `Nails:pup` 1168 initial −
86 survivors = 1082 destroys.
The engine's previously latent nil-map write on `bg.Tech` (would
have paniced on any group with non-empty Tech) is fixed in the same
patch — it blocked the aggregation regression test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two more KNNTS041 viewer fixes:
1. Phantom-frame fast-forward. `buildFrames` now flags every frame
whose shot landed on an already-empty defender group as
`phantom: true`. During play the BattleViewer effect detects a
phantom frame and chains a 0 ms timer to the next non-phantom,
so streaks of phantoms (the ~30 frames between shots 224 and
255, and the 401..414 stretch) collapse from "the player just
mots the timeline" into a single visual tick. Step controls and
the scrubber can still land on a phantom deliberately for
protocol inspection.
2. Final-frame layout freeze. `displayFrame` derives from the raw
`frames[i]` and, on the very last frame when `activeRaceIds`
shrinks vs the penultimate frame (the killing blow eliminates a
race), substitutes the penultimate's `remaining` and
`activeRaceIds` while keeping the current `shotIndex` and
`lastAction`. The result: the surviving cluster no longer
reflows onto the planet ring on the very last shot — the user
sees the killing line + defender flash rendered against the
picture they saw a moment earlier.
Tests: `phantom-destroy clamp` case extended with `frame.phantom`
flag assertions across the protocol; 644 Vitest cases stay green,
4 Playwright `battle-viewer` cases stay green.
Docs: `ui/docs/battle-viewer-ux.md` documents the fast-forward
behaviour and the final-frame freeze.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Layout reshuffle so the scene captures the maximum viewer area:
- Header collapses three rows into one: `back to map` / `back to
report` on the left, the centred title `Battle on planet <name>
(#<number>)` (new i18n key `game.battle.header_title`), and the
frame counter on the right. The wrapper `.active-view` no longer
renders its own back-row; routes flow through props.
- Viewer drops the `max-width: 880px` cap so on a wide monitor the
scene scales up across the full active-view-host.
- A drag-seek `<input type="range">` sits between the scene and the
controls; dragging pauses playback and lands `frameIndex` on the
chosen shot.
- Speed control is one cycling button: `1x → 2x → 4x → 6x → 1x`.
The label shows the current speed; the new 6x adds a 67 ms frame
interval for skimming a long timeline.
- The text protocol log is now collapsible behind a `Log ▲▼`
toggle in the controls bar. The toggle is its own button; the
default state stays expanded. Collapsing the log hands the
remaining height to the scene.
- Numerical list markers (`1. 2. 3.`) are dropped from the log;
`list-style: none` keeps each row visually clean.
Static cluster + visibility filter:
- `staticBucketsByRace` now locks bucket order, mass, radius and
local Vogel-spiral positions for the lifetime of the viewer; it
only re-derives when `report` or the wasm `core` change.
- `renderedByRace` overlays the per-frame `remaining` map and drops
buckets whose `numLeft` hits zero. The surviving buckets keep
their slots, so a class emptying never reshuffles the cluster —
the empty bucket simply disappears.
- A shot whose attacker or defender bucket is no longer visible
draws no line (phantom shots into already-empty buckets are
silently skipped, matching the user expectation that pup at 0
should stop attracting fire visually).
- Race label clamps to a minimum y inside the SVG viewport so
three-or-more-race layouts with a north anchor never clip the
top race name off-canvas.
Duel layout (user suggestion):
- `layoutRaces` rotates the radial start angle by 90° when only
two participants remain, so race 0 lands at 9 o'clock and race 1
at 3 o'clock. The pair faces off horizontally; neither label
pushes against the SVG top edge. The existing test for two-race
positions is updated accordingly.
Tests: the existing `layoutRaces` two-race case is rewritten for
the horizontal duel; the `game-shell-stubs` battle case checks the
loading placeholder (back buttons now live in the loaded viewer,
not the wrapper). 644 Vitest cases stay green; 4 Playwright
battle-viewer cases stay green.
Docs: `ui/docs/battle-viewer-ux.md` documents the static cluster /
visibility filter, the duel layout, the scrubber, the cycling
speed button and the collapsible log.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nine BattleViewer refinements from the latest review pass:
1. Mass radii were uniform in synthetic mode because
`+layout.svelte` skipped `loadCore()` on the synthetic branch.
The wasm bridge to `pkg/calc/ship.go` now boots in both modes
so `computeBattleGroupMass` resolves a real FullMass and
`radiusForMass` produces a per-battle scale.
2. Phantom-destroy clamp in `buildFrames`. Legacy emitters
(KNNTS041 planet #7) log many more `Destroyed` lines against a
group than the group's initial population — at frame 406 of
2317 the race totals previously hit zero on phantom shots and
the scene blanked while playback continued silently. We now
only shrink the per-group remaining count and the race totals
when the group still has ships. The line still draws on
phantom frames; only the counters stay sane.
3. Vogel sunflower positions are now reassigned by inward dot
product before being handed to ranks: the rank-0 bucket — the
one with the largest initial ship count — always lands at the
most-inward spiral slot. The previous quarter-step anchor bias
was too weak; ranks r ≥ 2 routinely overtook rank-0 toward
the planet. The anchor offset is gone.
4. Bucket order inside a cluster is locked at battle start by
each bucket's *initial* ship count (`num`), not its live
`numLeft`. The position of every class circle stays put for
the whole battle; only the label number changes as ships die.
5. Shot line + defender flash blink on a per-frame timer during
play. The line stays on for the first 90 % of frame duration,
off for the last 10 %, so two consecutive shots from the same
attacker on the same defender look like two distinct pulses.
On pause the line and flash stay drawn for inspection.
6. The defender's class circle now flashes red (destroyed) or
green (shielded) in sync with the shot line, so the eye
catches *who* was hit, not just where the line lands.
7. Battle log rows are buttons. Click / Enter / Space pauses
playback and seeks to that shot. The list also auto-scrolls
the current row into view so the highlight does not race off
the bottom on long battles.
8. Race labels now sit above the cloud's bounding top instead of
a fixed offset, so a dense cluster does not swallow its own
race name.
9. Planet glyph + label switch to neutral grey
(`#2a2f40` / `#4a5066` / `#6d7388`), keeping the planet "in the
background" rather than competing with the combatants.
Step-back icon switched to `◀︎◀︎` to mirror step-forward.
Tests: two new Vitest cases cover the phantom-destroy clamp
(single-race wipe, mixed-class race survives a class wipe). The
existing 642 Vitest tests stay green; all four `battle-viewer`
Playwright cases pass.
Docs: `ui/docs/battle-viewer-ux.md` rewrites the cluster section
(locked order + Vogel reassignment), adds Playback Details (blink
+ flash semantics), and a Phantom Destroys section explaining the
clamp.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>