/** * One drawn mark per app, for the tile the switcher rows and the launcher cards * share. * * Colour says nothing here any more: every app is Exxat's pink and only Exxat One * differs (`product-brand.ts`), so the drawing is the whole of what identifies a * row. That raises the bar on this file — the hue used to carry a share of the * recognition and now it carries none — and it is why the rules below are about * silhouette rather than detail. * * A Font Awesome glyph could not carry that: the set is drawn at one optical * weight for running text beside a label, so a hairline glyph in a saturated * square reads as a smudge. These are drawn for the square instead, on a 24 grid, * at a weight that survives 15px. * * ## Two hands, one grid * * Five marks are the supplied artwork, brought over at the coordinates they were * authored at and fitted to the grid by {@link fitted} rather than retraced. * Retracing a supplied mark by eye is how a set drifts: the curve of the trend * and the taper of the ribbon carry the drawing, and both are the first casualty * of a redraw. The rest are drawn here, because no artwork was supplied for them. * * The supplied five are solid shapes with their detail cut out; the remaining * four are outlines at 2.5–3.2. That is a visible difference in weight, and it is * the honest state of the set rather than a decision — the four are next in line * to be redrawn solid, and until they are, the tile they share does the work of * making them look related. * * ## Rules the set follows * * 1. **One silhouette class each.** Only one mark is a ring cluster, only one is a * shield, only one is a diagonal, only one is a curve. No two are "a rounded * blob with detail inside", because at tile size detail is the first thing to go * and silhouette is the last. The tile is round now, which spends part of this * budget: the medal's disc sits inside a circle and only its ribbons keep the * two outlines apart. * 2. **Detail is cut out, never laid on.** The mark is one colour, so a check * drawn in the same white as the shield under it is invisible. A second * subpath wound against the first turns it into a hole and the tile colour * reads through it. * 3. **`currentColor` only.** The tile sets the glyph white * (`.product-tile-glyph`), and a mark that named its own colour would break * the moment a tenant recoloured the tile under it. Supplied artwork arrives * with its fills named, and they are stripped on the way in. * 4. **No text, no gradients, no strokes under 2.4.** All three vanish at 15px. * * Products without a mark fall back to their Font Awesome glyph. Six do: * `exxat-custom` and Clinical Education (`exxat-prism`), for the reasons below; * Exxat One (school side), Administrator, Exam Management, and Surveys, whose * solid or sharp glyphs read at tile weight on brand circles without a stroked SVG * outline. * * The Exxat circular logo is a different job and lives in `ProductMark` * (`components/product-wordmark.tsx`): that one is the company signing the app, * this one says which app. */ import type * as React from "react" import type { Product } from "@/contexts/product-context" import { PRODUCT_TILE_GLYPH_CLASS, PRODUCT_TILE_SIZES, productGlyph, productTileGlyph, type ProductTileSize, } from "@/lib/product-glyph" import { cn } from "@/lib/utils" /** The box a piece of supplied artwork was authored in. */ type ArtBox = { x?: number; y?: number; width: number; height: number } /** * How much of the 24 grid a mark's longest side may use. * * The marks drawn here run 18 to 22 across, because a glyph needs air inside the * tile to read as a shape rather than as a filled square. Artwork fitted to the * full 24 is the largest thing in the menu by a visible margin, so the set looks * like two different sizes; 21 puts the supplied five where the clipboard and the * shield already were. */ const ART_EXTENT = 21 const trim = (value: number) => Number(value.toFixed(4)) /** * Place artwork authored at its own size on the shared 24 grid. * * Uniform scale about the box's centre, so the drawing keeps its proportions and * lands where every other mark lands. `width`/`height` are the part of the * artwork that should fill the grid, not necessarily the file's `viewBox`: the * bar chart arrived inside a rounded square that was the tile it was mocked on, * and fitting that square would have shrunk the bars to two thirds and left them * off-centre. * * Scaling rather than rewriting the numbers means the marks stay diffable against * the files they came from. Nothing here is stroked, so there is no stroke width * to scale with it. */ function fitted(box: ArtBox, art: React.ReactNode) { const { x = 0, y = 0, width, height } = box const scale = Math.min(ART_EXTENT / width, ART_EXTENT / height) const dx = (24 - width * scale) / 2 - x * scale const dy = (24 - height * scale) / 2 - y * scale return {art} } /** * Accreditation's disc with the star cut out of it. The circle is written as two * arcs rather than a `` so the star can share the path and become a hole * under `evenodd`. */ const MEDAL_DISC = "M0 44a44 44 0 1 0 88 0 44 44 0 1 0-88 0Z" + "M41.23 21.6598C42.2548 19.1959 45.7452 19.1959 46.77 21.6598L51.7604 33.6583C52.1924 " + "34.697 53.1693 35.4067 54.2906 35.4966L67.244 36.5351C69.904 36.7483 70.9826 40.0678 " + "68.9559 41.8038L59.0868 50.2578C58.2325 50.9896 57.8594 52.138 58.1204 53.2322L61.1355 " + "65.8725C61.7547 68.4682 58.931 70.5198 56.6537 69.1288L45.5638 62.3551C44.6037 61.7687 " + "43.3963 61.7687 42.4362 62.3551L31.3463 69.1288C29.069 70.5198 26.2453 68.4682 26.8644 " + "65.8725L29.8796 53.2322C30.1406 52.138 29.7675 50.9896 28.9132 50.2578L19.0441 " + "41.8038C17.0175 40.0678 18.096 36.7483 20.756 36.5351L33.7094 35.4966C34.8307 35.4067 " + "35.8076 34.697 36.2396 33.6583L41.23 21.6598Z" /** The two tails under it, hung from the same centre. */ const MEDAL_RIBBONS = "M25.2311 93.5807L17.375 97.1813C13.6096 98.9072 11.9559 103.359 13.6817 107.125C15.4075 " + "110.89 19.8596 112.544 23.625 110.818L44.5391 101.232L64.9405 110.347C68.7223 112.037 " + "73.158 110.341 74.8477 106.559C76.5374 102.777 74.8414 98.3418 71.0596 96.652L63.5275 " + "93.2866C57.4845 95.6829 50.896 96.9999 44 96.9999C37.3914 96.9999 31.0654 95.7904 " + "25.2311 93.5807Z" /** Student & Program Success — the curve the dot and the arrow sit on. */ const SUCCESS_TREND = "M92.5191 41.8331C93.992 40.1868 96.5211 40.0459 98.1675 41.5186C99.8137 42.9915 99.9547 " + "45.5207 98.482 47.167C94.3229 51.8154 88.6779 58.4116 82.8989 63.8301C79.998 66.5502 " + "76.945 69.089 73.8989 70.9952C70.9092 72.8661 67.572 74.3542 64.1675 74.4961C56.8468 " + "74.8012 52.7898 72.0382 49.1841 69.8624C45.9072 67.8849 42.5063 66 35.5005 66C23.3801 " + "66 12.3893 73.3874 6.15383 77.3711C4.29223 78.5603 1.81872 78.0149 0.629414 " + "76.1534C-0.559706 74.2918 -0.0143433 71.8183 1.84719 70.629C7.6119 66.9459 20.6212 58 " + "35.5005 58C44.4946 58 49.3439 60.6152 53.3169 63.0127C56.9611 65.2118 59.1547 66.6988 " + "63.8335 66.504C65.2288 66.4458 67.1667 65.7709 69.6548 64.2139C72.0869 62.692 74.7157 " + "60.5366 77.4273 57.9942C82.873 52.888 88.1783 46.6845 92.5191 41.8331Z" /** Curriculum Mapping — one leaf, then its mirror. Solid pages, one spine gap. */ const BOOK_LEFT_LEAF = "M37 80L37.5065 14.9068C37.5065 14.9068 33 7.5 23 4C13 0.5 0.493506 0 0.493506 0L0 " + "70.0621C0 70.0621 17.2159 68.78 23.5 71C29.7841 73.22 37 80 37 80Z" const BOOK_RIGHT_LEAF = "M50.0066 80L49.5001 14.9068C49.5001 14.9068 54.0066 7.5 64.0066 4C74.0066 0.5 86.5131 0 " + "86.5131 0L87.0066 70.0621C87.0066 70.0621 69.7907 68.78 63.5066 71C57.2225 73.22 " + "50.0066 80 50.0066 80Z" /** * Compliance — a shield with the check cut out. The two subpaths wind against * each other, so the hole is the default fill rule's work and needs no * `evenodd`. */ const COMPLIANCE_SHIELD = "M81 16.5V51C81 51 76.0058 68.5472 67.583 77.5C59.1771 86.4347 39.8328 94.9656 39.7549 " + "95C39.7549 95 18.2858 83.9264 11.4297 75C4.57363 66.0736 0 48.5 0 48.5V19L41.2451 " + "0L81 16.5ZM61.1279 37.25C59.433 35.4326 56.5851 35.3335 54.7676 37.0283L36.1172 " + "54.4189L30.0879 47.9541C28.393 46.1365 25.5452 46.0366 23.7275 47.7314C21.91 49.4264 " + "21.8109 52.2742 23.5059 54.0918L32.3721 63.5996C32.4137 63.6442 32.457 63.6869 32.5 " + "63.7295C32.5346 63.7692 32.5693 63.8098 32.6055 63.8486C34.3004 65.6658 37.1474 65.765 " + "38.9648 64.0703L60.9062 43.6104C62.7235 41.9154 62.8227 39.0675 61.1279 37.25Z" const MARKS: Partial> = { // Clinical Education is deliberately absent: it wears Font Awesome's solid // stethoscope, which `ProductArt` falls back to. The drawn one was a stroked // outline, and an outline is the wrong weight next to the solid marks below it // — at 22px its 2.7 stroke landed thinner than the fills either side of it in // the switcher column and the row read as unfinished rather than as a different // app. Solid also fixes the shape: the drawn bell was a plain circle, near // enough to Exxat One's rings on a round tile to be mistaken for them. // Exxat One, site side — the same cluster with the top ring filled, which is // the site's own node. Only one of the two sides is ever in a workspace, so // this only has to differ from the school's, not pair with it. "exxat-one-sites": ( <> ), // Student & Program Success — a cohort, and where it is heading. The dot is one // of them; the curve is the whole group's direction, dipping before it climbs, // which a straight line would have flattered. The only diagonal and the only // curve in the set, so movement is what the silhouette says first. "exxat-student-success": fitted( { width: 100, height: 78 }, <> {/* Coordinates carry 2 decimals, the precision this 100-unit box can show once it is scaled to 21. The export's 4 were bytes, not pixels. */} , ), // Curriculum Mapping — an open book. Two leaves off one spine, which is a // silhouette nothing else here has, and solid pages rather than outlined ones: // at tile size an outlined leaf is two hairlines with a sliver of tile between // them, and the sliver is what closes up first. "exxat-curriculum-mapping": fitted( { width: 87, height: 80 }, <> , ), // Compliance — a shield, cleared. The only pointed base. "exxat-compliance": fitted({ width: 81, height: 95 }, ), // Accreditation — a medal: a star cut out of the disc, on two tails. The disc // is what separates it from a bare star, and cutting the star rather than // laying it on is what keeps it visible on a one-colour mark. "exxat-accreditation": fitted( { width: 88, height: 112 }, <> , ), // Design System — swatches, stacked. Squares, which nothing else in the set is. "exxat-design-os": ( <> ), } /** Whether this product has a drawn mark, or should fall back to its glyph. */ export function hasProductAppMark(product: Product): boolean { return MARKS[product] !== undefined } /** * The mark, sized by the caller. Decorative: every tile that renders one sits * beside the product's name, so the name is the accessible one. */ export function ProductAppMark({ product, className, }: { product: Product className?: string }) { const art = MARKS[product] if (!art) return null return ( ) } /** * A product's identity art, whichever kind it has: the drawn mark, or the Font * Awesome glyph for products that have none. * * Two class props rather than one because the two are sized differently — an svg * by its box, a font glyph by its font-size — and a caller that passed `size-4` * to both would get a glyph with no size at all. */ export function ProductArt({ product, markClassName, glyphClassName, glyphOnly = false, }: { product: Product markClassName?: string glyphClassName?: string /** Skip drawn marks and render the Font Awesome tile glyph instead. */ glyphOnly?: boolean }) { if (!glyphOnly && hasProductAppMark(product)) { return } return (