diff --git a/DESIGN-vercel.md b/DESIGN-vercel.md new file mode 100644 index 0000000..f1c1731 --- /dev/null +++ b/DESIGN-vercel.md @@ -0,0 +1,736 @@ +--- +version: alpha +name: Vercel-design-analysis +description: An inspired interpretation of Vercel's design language — a developer-platform brand whose surface is a stark black-and-ink duet on near-white canvas, broken at hero scale by a multi-color mesh gradient (cyan / blue / magenta / amber) that acts as the entire decorative system, paired with a custom geometric sans for headlines and a monospaced caption face for technical labels. + +colors: + primary: "#171717" + on-primary: "#ffffff" + ink: "#171717" + body: "#4d4d4d" + mute: "#888888" + hairline: "#ebebeb" + hairline-strong: "#a1a1a1" + canvas: "#ffffff" + canvas-soft: "#fafafa" + canvas-soft-2: "#f5f5f5" + link: "#0070f3" + link-deep: "#0761d1" + link-bg-soft: "#d3e5ff" + success: "#0070f3" + error: "#ee0000" + error-soft: "#f7d4d6" + error-deep: "#c50000" + warning: "#f5a623" + warning-soft: "#ffefcf" + warning-deep: "#ab570a" + violet: "#7928ca" + violet-soft: "#d8ccf1" + violet-deep: "#4c2889" + cyan: "#50e3c2" + cyan-soft: "#aaffec" + cyan-deep: "#29bc9b" + highlight-pink: "#ff0080" + highlight-magenta: "#eb367f" + gradient-develop-start: "#007cf0" + gradient-develop-end: "#00dfd8" + gradient-preview-start: "#7928ca" + gradient-preview-end: "#ff0080" + gradient-ship-start: "#ff4d4d" + gradient-ship-end: "#f9cb28" + selection-bg: "#171717" + selection-fg: "#f2f2f2" + +typography: + display-xl: + fontFamily: Geist, Inter, system-ui, -apple-system, sans-serif + fontSize: 48px + fontWeight: 600 + lineHeight: 48px + letterSpacing: -2.4px + display-lg: + fontFamily: Geist, Inter, system-ui, -apple-system, sans-serif + fontSize: 32px + fontWeight: 600 + lineHeight: 40px + letterSpacing: -1.28px + display-md: + fontFamily: Geist, Inter, system-ui, -apple-system, sans-serif + fontSize: 24px + fontWeight: 600 + lineHeight: 32px + letterSpacing: -0.96px + display-sm: + fontFamily: Geist, Inter, system-ui, -apple-system, sans-serif + fontSize: 20px + fontWeight: 600 + lineHeight: 28px + letterSpacing: -0.6px + body-lg: + fontFamily: Geist, Inter, system-ui, -apple-system, sans-serif + fontSize: 18px + fontWeight: 400 + lineHeight: 28px + letterSpacing: 0px + body-md: + fontFamily: Geist, Inter, system-ui, -apple-system, sans-serif + fontSize: 16px + fontWeight: 400 + lineHeight: 24px + body-md-strong: + fontFamily: Geist, Inter, system-ui, -apple-system, sans-serif + fontSize: 16px + fontWeight: 500 + lineHeight: 24px + body-sm: + fontFamily: Geist, Inter, system-ui, -apple-system, sans-serif + fontSize: 14px + fontWeight: 400 + lineHeight: 20px + letterSpacing: -0.28px + body-sm-strong: + fontFamily: Geist, Inter, system-ui, -apple-system, sans-serif + fontSize: 14px + fontWeight: 500 + lineHeight: 20px + letterSpacing: -0.28px + caption: + fontFamily: Geist, Inter, system-ui, -apple-system, sans-serif + fontSize: 12px + fontWeight: 400 + lineHeight: 16px + caption-mono: + fontFamily: Geist Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, monospace + fontSize: 12px + fontWeight: 400 + lineHeight: 16px + code: + fontFamily: Geist Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, monospace + fontSize: 13px + fontWeight: 400 + lineHeight: 20px + button-md: + fontFamily: Geist, Inter, system-ui, -apple-system, sans-serif + fontSize: 14px + fontWeight: 500 + lineHeight: 20px + button-lg: + fontFamily: Geist, Inter, system-ui, -apple-system, sans-serif + fontSize: 16px + fontWeight: 500 + lineHeight: 24px + +rounded: + none: 0px + xs: 4px + sm: 6px + md: 8px + lg: 12px + xl: 16px + pill-sm: 64px + pill: 100px + full: 9999px + +spacing: + xxs: 4px + xs: 8px + sm: 12px + md: 16px + lg: 24px + xl: 32px + 2xl: 40px + 3xl: 48px + 4xl: 64px + 5xl: 96px + 6xl: 128px + section: 192px + +components: + nav-bar: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + typography: "{typography.body-sm}" + height: 64px + padding: "{spacing.sm} {spacing.lg}" + nav-link: + textColor: "{colors.body}" + typography: "{typography.body-sm}" + rounded: "{rounded.full}" + padding: "{spacing.xs} {spacing.sm}" + nav-cta-signup: + backgroundColor: "{colors.primary}" + textColor: "{colors.on-primary}" + typography: "{typography.body-sm-strong}" + rounded: "{rounded.sm}" + padding: "0px {spacing.xs}" + height: 28px + nav-cta-login: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + typography: "{typography.body-sm-strong}" + rounded: "{rounded.sm}" + padding: "0px {spacing.xs}" + height: 28px + nav-cta-ask-ai: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + borderColor: "{colors.hairline}" + typography: "{typography.body-sm-strong}" + rounded: "{rounded.sm}" + padding: "0px {spacing.xs}" + height: 28px + button-primary: + backgroundColor: "{colors.primary}" + textColor: "{colors.on-primary}" + typography: "{typography.button-lg}" + rounded: "{rounded.pill}" + padding: "0px {spacing.sm}" + button-secondary: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + typography: "{typography.button-lg}" + rounded: "{rounded.pill}" + padding: "0px {spacing.sm}" + button-primary-sm: + backgroundColor: "{colors.primary}" + textColor: "{colors.on-primary}" + typography: "{typography.button-md}" + rounded: "{rounded.pill}" + padding: "0px {spacing.xs}" + button-secondary-sm: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + typography: "{typography.button-md}" + rounded: "{rounded.pill}" + padding: "0px {spacing.xs}" + tab-ghost: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + typography: "{typography.body-sm}" + rounded: "{rounded.pill-sm}" + padding: "0px {spacing.md}" + icon-button-circular: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + borderColor: "{colors.hairline}" + rounded: "{rounded.full}" + card-marketing: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + typography: "{typography.body-md}" + rounded: "{rounded.md}" + padding: "{spacing.lg}" + card-marketing-large: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + typography: "{typography.body-md}" + rounded: "{rounded.lg}" + padding: "{spacing.xl}" + card-soft: + backgroundColor: "{colors.canvas-soft}" + textColor: "{colors.ink}" + typography: "{typography.body-md}" + rounded: "{rounded.md}" + padding: "{spacing.lg}" + template-card: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + typography: "{typography.body-md}" + rounded: "{rounded.md}" + padding: "{spacing.md}" + code-editor-mockup: + backgroundColor: "{colors.primary}" + textColor: "{colors.on-primary}" + typography: "{typography.code}" + rounded: "{rounded.md}" + padding: "{spacing.lg}" + form-input: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + borderColor: "{colors.hairline}" + typography: "{typography.body-sm}" + rounded: "{rounded.sm}" + padding: "0px {spacing.sm}" + height: 40px + form-input-sm: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + borderColor: "{colors.hairline}" + typography: "{typography.body-sm}" + rounded: "{rounded.sm}" + padding: "0px {spacing.sm}" + height: 32px + form-input-lg: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + borderColor: "{colors.hairline}" + typography: "{typography.body-md}" + rounded: "{rounded.sm}" + padding: "0px {spacing.sm}" + height: 48px + badge-secondary: + backgroundColor: "{colors.canvas-soft}" + textColor: "{colors.body}" + typography: "{typography.caption}" + rounded: "{rounded.full}" + padding: "0px {spacing.xs}" + pricing-card: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + typography: "{typography.body-md}" + rounded: "{rounded.lg}" + padding: "{spacing.xl}" + pricing-card-featured: + backgroundColor: "{colors.primary}" + textColor: "{colors.on-primary}" + typography: "{typography.body-md}" + rounded: "{rounded.lg}" + padding: "{spacing.xl}" + logo-strip: + backgroundColor: "{colors.canvas}" + textColor: "{colors.body}" + typography: "{typography.body-sm}" + padding: "{spacing.lg} {spacing.xl}" + hero-band: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + typography: "{typography.display-xl}" + padding: "{spacing.4xl} {spacing.lg}" + feature-mesh-band: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + typography: "{typography.display-lg}" + padding: "{spacing.5xl} {spacing.lg}" + showcase-band-light: + backgroundColor: "{colors.canvas-soft}" + textColor: "{colors.ink}" + typography: "{typography.display-lg}" + padding: "{spacing.5xl} {spacing.lg}" + showcase-band-dark: + backgroundColor: "{colors.primary}" + textColor: "{colors.on-primary}" + typography: "{typography.display-lg}" + padding: "{spacing.5xl} {spacing.lg}" + footer: + backgroundColor: "{colors.canvas}" + textColor: "{colors.body}" + typography: "{typography.body-sm}" + padding: "{spacing.4xl} {spacing.lg}" + link-inline: + textColor: "{colors.link}" + typography: "{typography.body-md}" + banner-marketing: + backgroundColor: "{colors.canvas-soft}" + textColor: "{colors.body}" + typography: "{typography.body-sm}" + rounded: "{rounded.full}" + padding: "{spacing.xs} {spacing.sm}" + + # ─── Examples (illustrative) — auto-derived; resolve any TO_FILL markers below ─── + ex-pricing-tier: + description: "Default tier card. Mirrors pricing-card chrome on canvas-soft surface with a hairline border." + backgroundColor: "{colors.canvas-soft}" + textColor: "{colors.ink}" + borderColor: "{colors.hairline}" + rounded: "{rounded.lg}" + padding: "{spacing.xl}" + ex-pricing-tier-featured: + description: "Featured tier — polarity-flipped to ink primary with white text and white CTA." + backgroundColor: "{colors.ink}" + textColor: "{colors.on-primary}" + rounded: "{rounded.lg}" + padding: "{spacing.xl}" + ex-product-selector: + description: "What's Included summary card — repurposed for the brand's GPU / inference / Pro feature tiers." + backgroundColor: "{colors.canvas-soft}" + rounded: "{rounded.md}" + padding: "{spacing.lg}" + ex-cart-drawer: + description: "Subscription summary — line items per add-on (NOT a literal e-commerce cart)." + backgroundColor: "{colors.canvas}" + rounded: "{rounded.md}" + padding: "{spacing.lg}" + item-divider: "{colors.hairline}" + ex-app-shell-row: + description: "Sidebar nav row. Active state uses brand primary as a left-edge indicator bar." + backgroundColor: "{colors.canvas}" + activeIndicator: "{colors.primary}" + rounded: "{rounded.sm}" + padding: "{spacing.xs} {spacing.sm}" + ex-data-table-cell: + description: "Mirrors the brand's table chrome. Header uses caption-mono uppercase mono; body uses body-sm." + headerBackground: "{colors.canvas-soft}" + headerTypography: "{typography.caption-mono}" + bodyTypography: "{typography.body-sm}" + cellPadding: "{spacing.xs} {spacing.sm}" + rowBorder: "{colors.hairline}" + ex-auth-form-card: + description: "Sign-in / sign-up card. Mirrors card-marketing-large chrome with form-input primitives inside." + backgroundColor: "{colors.canvas-soft}" + rounded: "{rounded.lg}" + padding: "{spacing.xl}" + ex-modal-card: + description: "Modal dialog surface — same chrome as card-marketing-large with Level 5 modal shadow." + backgroundColor: "{colors.canvas}" + rounded: "{rounded.lg}" + padding: "{spacing.xl}" + ex-empty-state-card: + description: "Empty-state illustration frame. Generous padding on canvas-soft." + backgroundColor: "{colors.canvas-soft}" + rounded: "{rounded.lg}" + padding: "{spacing.3xl}" + captionTypography: "{typography.body-md}" + ex-toast: + description: "Toast notification surface — flat-cornered card-marketing chrome with Level 4 shadow." + backgroundColor: "{colors.canvas}" + rounded: "{rounded.md}" + padding: "{spacing.sm} {spacing.md}" + typography: "{typography.body-sm}" + +--- + + +## Overview + +Vercel is a developer-platform brand — the page is a deployment dashboard's marketing surface, written for engineers who already know the syntax. It earns that posture with one of the cleanest stark systems on the web: near-white `{colors.canvas-soft}` body background, ink-near-black `{colors.ink}` text, a 200-step gray scale that gives every divider, border, and disabled state its own deliberate step. The only place the brand introduces colour at marketing scale is the multi-stop mesh gradient (`{colors.gradient-develop-start}` → `{colors.gradient-preview-end}` → `{colors.gradient-ship-start}` → cyan / magenta / amber) that floats in atmospheric backdrops, never miniaturised to a swatch. That gradient is the entire decoration system. + +Type is the second decisive voice. The brand's own custom geometric sans (Geist) carries display, body, button — everything narrative — at weight 600 for display, 500 for buttons, 400 for body. A matching monospaced face (Geist Mono) carries technical labels: terminal mockups, code blocks, sometimes filename captions. Headlines are sentence-case with aggressive negative letter-spacing (`-2.4px` at 48 px hero) — the brand never letter-spaces positively, never goes uppercase outside of mono labels. + +Surfaces use a four-step ladder: `{colors.canvas}` (pure white for cards), `{colors.canvas-soft}` 98% (the page body), `{colors.canvas-soft-2}` 95% (occasional inset region), `{colors.primary}` (the deep ink-near-black used as the polarity-flipped band when a section needs the dark mode treatment). Shadows are exceptionally subtle — every elevated card carries a stacked shadow built from `0px 1px 1px #00000005` + `0px 2px 2px #0000000a` + an inset border. Cards never float on heavy drop-shadow; they sit on the page held by hairline + soft glow. + +**Key Characteristics:** +- A single black-ink primary CTA `{colors.primary}` carries every conversion target, paired with white-on-white `button-secondary` for the secondary action. The brand uses 100 px pill shape for marketing CTAs and a tight 6 px square shape for in-app nav buttons. +- A multi-stop mesh gradient (cyan-blue-magenta-amber) is the only decorative chrome — used at hero scale and inside feature-band atmospheric backdrops. It is the brand. +- Every section eyebrow and small label uses the monospace face `{typography.caption-mono}` or `{typography.code}`; everything else is in the geometric sans. +- Subtle stacked-shadow elevation — three offsets layered with 4-12 % black opacity — never a single heavy drop-shadow. +- A complete 100–1000 gray + blue + red + amber + green + teal + purple + pink colour scale exists as a system token set, but the marketing surface uses only the `100`, `1000`, and `700`-level tones; the rest stay in the design-system tokens for in-product surfaces. +- An "Active CPU" pricing rhythm: `pricing-card` lays out 3-up on the pricing page with `pricing-card-featured` (Pro tier) polarity-flipped to `{colors.primary}` against white-card siblings. + +## Colors + +### Brand & Accent +- **Ink** (`{colors.primary}` — `#171717`): The single primary CTA color. Black-near-pure ink that carries every Sign Up pill, every footer CTA, the dark-band polarity-flip. Used as text color throughout the page on light surfaces. (Resolved from `--ds-gray-1000`.) +- **Cyan** (`{colors.cyan}` — `#50e3c2`): A signature mint-cyan used in the brand gradient and inside Geist-system spotlight tokens. Visible inside the hero gradient stops. +- **Highlight Pink** (`{colors.highlight-pink}` — `#ff0080`): The brand's highlight magenta, used as the high-saturation stop in the preview-gradient pair. +- **Violet** (`{colors.violet}` — `#7928ca`): The deep purple used as the start of the preview-gradient and inside developer-console highlights. +- **Link Blue** (`{colors.link}` — `#0070f3`): The brand's primary link color and the legacy `--geist-success` semantic. + +### Surface +- **Canvas** (`{colors.canvas}` — `#ffffff`): The pure-white card / dialog / modal surface. +- **Canvas Soft** (`{colors.canvas-soft}` — `#fafafa`): The default page background — 98 % white. Almost every section sits on this tone. +- **Canvas Soft 2** (`{colors.canvas-soft-2}` — `#f5f5f5`): A slightly deeper inset surface for "code editor inner background", template-card hover states, and dropdown menus. +- **Hairline** (`{colors.hairline}` — `#ebebeb`): 1 px dividers — table rows, card borders, input borders. +- **Hairline Strong** (`{colors.hairline-strong}` — `#a1a1a1`): The 500-level gray, used as the slightly-stronger divider on light bands and as the deemphasised text color. + +### Text +- **Ink** (`{colors.ink}` — `#171717`): Every heading and body paragraph on light surfaces. +- **Body** (`{colors.body}` — `#4d4d4d`): Secondary text — sub-headings, body captions, nav-link inactive text, footer column body. +- **Mute** (`{colors.mute}` — `#888888`): Lowest-priority text — placeholder text, fine print, low-key labels. +- **On Primary** (`{colors.on-primary}` — `#ffffff`): All text on `{colors.primary}` surfaces. + +### Semantic +- **Success / Link** (`{colors.success}` — `#0070f3`): The brand's legacy success indicator doubles as the primary link color. Visible underline-on-hover for inline body links. +- **Link Deep** (`{colors.link-deep}` — `#0761d1`): The pressed / visited tone for inline links. +- **Link Bg Soft** (`{colors.link-bg-soft}` — `#d3e5ff`): Soft pastel blue fill for "what's new" pill banners and informational badges. +- **Error** (`{colors.error}` — `#ee0000`): Validation red for destructive actions and form errors. +- **Error Soft** (`{colors.error-soft}` — `#f7d4d6`): Soft pastel red for destructive-state backgrounds. +- **Error Deep** (`{colors.error-deep}` — `#c50000`): Pressed / deep destructive state. +- **Warning** (`{colors.warning}` — `#f5a623`): Caution / pending status indicator. +- **Warning Soft** (`{colors.warning-soft}` — `#ffefcf`) / **Warning Deep** (`{colors.warning-deep}` — `#ab570a`): Background + pressed variants. + +### Brand Gradient +The brand's signature decoration is a three-pair gradient stack: +- **Develop** (`{colors.gradient-develop-start}` `#007cf0` → `{colors.gradient-develop-end}` `#00dfd8`) — the blue-to-teal pair used to mark the "deploy" / "develop" rhythm. +- **Preview** (`{colors.gradient-preview-start}` `#7928ca` → `{colors.gradient-preview-end}` `#ff0080`) — the violet-to-pink pair used for "preview" surfaces. +- **Ship** (`{colors.gradient-ship-start}` `#ff4d4d` → `{colors.gradient-ship-end}` `#f9cb28`) — the coral-to-amber pair used for "ship" surfaces. + +The three pairs collapse into a single multi-color mesh gradient when used as the hero atmospheric backdrop. Treat the gradient as one unified object — do not crop down to a single colour, do not reorder the stops, and do not miniaturise. Used at hero scale only. + +## Typography + +### Font Family +Two custom faces carry the entire system: + +1. **A custom geometric sans** (extracted as `Geist`) for every display, body, button, link, and label. Weights 400 / 500 / 600 are the working set; the face never appears in 700 or heavier. Display sizes are tracked aggressively negative (`-2.4 px` at 48 px hero, `-1.28 px` at 32 px section); body stays at neutral or slightly-negative tracking. +2. **A custom monospaced face** (extracted as `Geist Mono`) for terminal mockups, code blocks, and small mono-caption labels — anything that wants to signal "technical." Weight 400 only at 12 – 13 px. Tracking neutral. + +A condensed display sans (`Space Grotesk`) is loaded as a third face for occasional editorial moments but does not render as the primary face anywhere in the captured surfaces. + +### Hierarchy + +| Token | Size | Weight | Line Height | Letter Spacing | Use | +|---|---|---|---|---|---| +| `{typography.display-xl}` | 48px | 600 | 48px | -2.4px | Hero headline ("Build and deploy on the AI Cloud."). | +| `{typography.display-lg}` | 32px | 600 | 40px | -1.28px | Section headlines ("Your frontend, delivered.", "A compute model for all workloads."). | +| `{typography.display-md}` | 24px | 600 | 32px | -0.96px | Card-cluster headlines, pricing-tier names. | +| `{typography.display-sm}` | 20px | 600 | 28px | -0.6px | Inline display micro-headings. | +| `{typography.body-lg}` | 18px | 400 | 28px | 0 | Lead paragraphs under section headlines. | +| `{typography.body-md}` | 16px | 400 | 24px | 0 | Default body paragraph. | +| `{typography.body-md-strong}` | 16px | 500 | 24px | 0 | Bolded inline body. | +| `{typography.body-sm}` | 14px | 400 | 20px | -0.28px | Secondary body, nav-link text, button-md labels. | +| `{typography.body-sm-strong}` | 14px | 500 | 20px | -0.28px | Nav CTA labels, table-row emphasis. | +| `{typography.caption}` | 12px | 400 | 16px | 0 | Footer secondary lines, badge labels. | +| `{typography.caption-mono}` | 12px | 400 | 16px | 0 | Section eyebrows and label captions that want a technical voice. | +| `{typography.code}` | 13px | 400 | 20px | 0 | Inline code, terminal mockups, command snippets. | +| `{typography.button-md}` | 14px | 500 | 20px | 0 | Small / nav-scale button labels. | +| `{typography.button-lg}` | 16px | 500 | 24px | 0 | Marketing-scale pill button labels. | + +### Principles +- **Negative tracking is part of the voice.** Display sizes use aggressive `-2.4` to `-0.6` px tracking. Reverting to default tracking breaks the brand. +- **Sentence-case headlines, period-terminated.** Headlines like "Build and deploy on the AI Cloud." end with a deliberate period — that punctuation is part of the brand's voice. +- **Mono for the technical layer only.** Section eyebrows, code blocks, terminal mockups. Body paragraphs never set in mono. +- **Weight 600 is the display ceiling.** The geometric sans never appears at 700 / 800. The brand reads as a calmer system because of this. + +### Note on Font Substitutes +The two primary faces are proprietary (custom-cut for the brand). Open-source substitutes: +- **Geometric sans** — *Inter* (400 / 500 / 600) is the closest stylistic match; `font-feature-settings: "ss01", "ss02"` enables the geometric alternates. *Satoshi* is a passable second choice. +- **Monospace** — *JetBrains Mono* (400) at 12 – 13 px matches the technical voice. *IBM Plex Mono* is the second-best option. + +## Layout + +### Spacing System +- **Base unit**: 4 px. The brand's `--geist-space` token is exactly 4 px and every captured value is a multiple of 4. +- **Tokens**: `{spacing.xxs}` 4 px · `{spacing.xs}` 8 px · `{spacing.sm}` 12 px · `{spacing.md}` 16 px · `{spacing.lg}` 24 px · `{spacing.xl}` 32 px · `{spacing.2xl}` 40 px · `{spacing.3xl}` 48 px · `{spacing.4xl}` 64 px · `{spacing.5xl}` 96 px · `{spacing.6xl}` 128 px · `{spacing.section}` 192 px. +- **Section padding**: marketing bands use `{spacing.4xl}` to `{spacing.5xl}` top/bottom. Hero bands stretch to `{spacing.section}` to give the mesh gradient room to breathe. +- **Card interior padding**: marketing cards sit at `{spacing.lg}` to `{spacing.xl}`; template-grid cards stay tighter at `{spacing.md}` because they sit in a denser grid. +- **Inline gap**: button rows, nav rows, and chip rows use `{spacing.sm}` to `{spacing.md}` between siblings. The brand's `--geist-gap` is exactly 24 px. + +### Grid & Container +- **Max width**: ~1400 px (`--ds-page-width`); the legacy `--geist-page-width` is 1200 px and still appears on some marketing surfaces. Content centres with horizontal gutters of `{spacing.lg}` 24 px on desktop, `{spacing.md}` 16 px on mobile. +- **Column patterns**: + - Three-feature row: 3-up at desktop, 1-up at mobile (rows like "Web Apps / Composable Commerce / Multi-tenant Platforms"). + - Tab pill row: 5-up centred row of `tab-ghost` pills. + - Template-grid cluster: 5-up at desktop, scaling to 1-up at mobile. + - Pricing tier grid: 3-up at desktop with the middle tier polarity-flipped. + - Logo strip: ~5 logos wide, single row. + +### Whitespace Philosophy +The mesh gradient does most of the heavy decorative lifting; whitespace separates the bands. Section spacing is generous — `{spacing.4xl}` to `{spacing.5xl}` between bands lets the gradient breathe. Inside a card, the headline/paragraph stack is tight (`{spacing.xs}` 8 px gap), then a wider gap before the CTA cluster. The page reads as engineered — large gaps + tight interior, never the other way around. + +### Responsive Strategy + +#### Breakpoints + +| Name | Width | Key Changes | +|---|---|---| +| Mobile | < 600px | Hero stacks; nav collapses to hamburger; 3-up feature grids drop to 1-up; tab pill row enables horizontal scroll. | +| Tablet | 600–959px | 3-up grids drop to 2-up; nav still horizontal. | +| Desktop | 960–1199px | Full 3-up grids; pricing 3-up. | +| Wide | 1200–1399px | Container caps at 1400 px content width. | +| Ultra-wide | ≥ 1400px | Content stays centred at 1400 px; bands stretch edge-to-edge in colour but content holds the max-width. | + +#### Touch Targets +The `button-primary` pill renders at ~32 px tall in nav and ~48 px tall in marketing contexts. Marketing CTAs comfortably meet WCAG AAA at all breakpoints; nav buttons inflate touch area through `{spacing.xs}` padding on mobile to meet the 44 × 44 px floor. + +#### Collapsing Strategy +- **Nav**: full link row + Ask AI / Log In / Sign Up pills at desktop. Collapses to logo + hamburger at mobile with the menu opening as a full-overlay. +- **Hero**: mesh gradient stays centred; headline + body stack vertically at all breakpoints (the brand doesn't use a split-hero pattern). +- **Three-feature row**: 3-up → 2-up → 1-up at the breakpoints above; cards keep their `{rounded.md}` 8 px shape across all viewports. +- **Pricing card grid**: 3-up at desktop, vertical stack at mobile with `pricing-card-featured` always sitting in the middle. +- **Template grid**: 5-up → 3-up → 2-up → 1-up. Each `template-card` keeps its 16:9 aspect on the image. + +#### Image Behavior +- **Mesh gradient**: rendered as inline SVG or canvas-painted gradient; scales fluidly with the hero container; never crops, never tiles. +- **Customer logos**: rendered as monochrome SVGs in the logo strip; consistent 24 px height. +- **Code editor mockup**: dark `{colors.primary}` rectangle with mono text rendered inside; treated as an image at the layout level. +- **Template thumbnails**: 16:9 landscape inside `{rounded.md}` card chrome; lazy-loaded; consistent grayscale palette in the placeholder state. + +## Elevation & Depth + +| Level | Treatment | Use | +|---|---|---| +| Level 0 — Flat | No shadow, no border. | Full-bleed hero bands and the polarity-flipped dark sections. | +| Level 1 — Inset Hairline | `0 0 0 1px #00000014` inset 1 px border. | Default card chrome — the brand's universal "you can see this card" cue. | +| Level 2 — Subtle Drop | `0px 1px 1px #00000005, 0px 2px 2px #0000000a` plus inset hairline. | Slightly elevated cards (template-grid, marketing-card). | +| Level 3 — Soft Stack | `0px 2px 2px #0000000a, 0px 8px 8px -8px #0000000a` plus inset hairline. | The "medium" elevation — feature-grid cards. | +| Level 4 — Float Stack | `0px 2px 2px #0000000a, 0px 8px 16px -4px #0000000a` plus inset hairline. | "Large" elevation — pricing cards, callout panels. | +| Level 5 — Modal | `0px 1px 1px #00000005, 0px 8px 16px -4px #0000000a, 0px 24px 32px -8px #0000000f` plus inset hairline. | Modal / dialog surfaces and dropdown menus. | + +The brand uses STACKED shadows — multiple small offsets layered to fake natural light — never a single 8-px-blur generic drop. Inset hairline rings are always added so the card edge stays crisp. + +### Decorative Depth +- **Mesh gradient as atmospheric depth**: the hero's multi-stop gradient is the brand's only "atmospheric" effect — applied as a flat 2-D backdrop rather than a 3-D illustration. +- **Polarity-flipped dark band as section-depth**: switching the surface from `{colors.canvas-soft}` to `{colors.primary}` (the deep ink) is the brand's chief depth cue between bands. +- **Inset-shadow + drop-shadow combo**: the cards' combination of an inset 1 px ring and a multi-stop drop produces a "card sits on the page" effect without ever feeling material-heavy. + +## Shapes + +### Border Radius Scale + +| Token | Value | Use | +|---|---|---| +| `{rounded.none}` | 0px | Full-bleed hero / footer bands. | +| `{rounded.xs}` | 4px | Tightest inline pill — the `nav-cta-signup` 6-px-radius button (mapped to `xs/sm`). | +| `{rounded.sm}` | 6px | The brand's `--geist-radius` token — base UI radius for in-app buttons, form inputs, dropdown menus. | +| `{rounded.md}` | 8px | The brand's `--geist-marketing-radius` token — feature cards, template cards. | +| `{rounded.lg}` | 12px | Slightly larger card chrome (pricing-card variants). | +| `{rounded.xl}` | 16px | Largest card chrome — when a card hosts a hero image cap. | +| `{rounded.pill-sm}` | 64px | Tab-ghost pills inside the "AI Apps / Web Apps / Ecommerce / Marketing / Platforms" row. | +| `{rounded.pill}` | 100px | The marketing CTA pill — `button-primary`, `button-secondary`, "Start Deploying" pill. | +| `{rounded.full}` | 9999px | Icon-button circular containers, nav-link ghost pills. | + +### Photography Geometry +- **Mesh gradient**: full-bleed 2-D atmospheric backdrop, never cropped to a frame; treated as the page's wallpaper. +- **Customer logos**: monochrome SVG, consistent 24 px height in a flex row. +- **Code editor mockup**: 16:10 dark rectangle, `{rounded.md}` corners. +- **Template thumbnails**: 16:9 landscape inside `{rounded.md}` chrome. +- **Showcase imagery**: 2:1 or 16:9 inside `{rounded.lg}` to `{rounded.xl}` chrome with a stacked shadow. + +## Components + +### Buttons + +**`button-primary`** — the canonical 100-px-radius black pill, marketing scale. +- Background `{colors.primary}`, text `{colors.on-primary}`, label set in `{typography.button-lg}`, padding `0px {spacing.sm}` 12 px, shape `{rounded.pill}` 100 px. Renders ~48 px tall when paired with the marketing flex layout. + +**`button-secondary`** — the white pill paired with the black primary inside marketing bands. +- Background `{colors.canvas}`, text `{colors.ink}`, same typography + padding as `button-primary`, shape `{rounded.pill}`. + +**`button-primary-sm`** — the smaller-scale primary pill used inside nav and pricing-card CTAs. +- Background `{colors.primary}`, text `{colors.on-primary}`, label set in `{typography.button-md}` (14 px / 500), shape `{rounded.pill}`. + +**`button-secondary-sm`** — the smaller-scale white pill paired with `button-primary-sm`. +- Background `{colors.canvas}`, text `{colors.ink}`, same typography + shape as `button-primary-sm`. + +**`tab-ghost`** — the centred-row tab pill ("AI Apps / Web Apps / Ecommerce / Marketing / Platforms"). +- Background `{colors.canvas}`, text `{colors.ink}`, label set in `{typography.body-sm}`, padding `0px {spacing.md}`, shape `{rounded.pill-sm}` 64 px. + +**`icon-button-circular`** — the circular icon container (often a "?" or arrow inside). +- Background `{colors.canvas}`, dark icon, 1 px solid hairline border, shape `{rounded.full}`. + +**Nav CTAs:** + +**`nav-cta-signup`** — the small black "Sign Up" button in the nav row. +- Background `{colors.primary}`, text `{colors.on-primary}`, label `{typography.body-sm-strong}`, padding `0px {spacing.xs}`, height 28 px, shape `{rounded.sm}` 6 px (the brand's `--geist-radius`). + +**`nav-cta-login`** — the white "Log In" button in the nav. +- Background `{colors.canvas}`, text `{colors.ink}`, same typography / height / shape as `nav-cta-signup`. + +**`nav-cta-ask-ai`** — the small "Ask AI" button with a faint border. +- Background `{colors.canvas}`, text `{colors.ink}`, 1 px solid `{colors.hairline}` border (extracted as `0px solid rgb(235, 235, 235)`), same typography / height / shape. + +### Cards & Containers + +**`card-marketing`** — the canonical marketing feature card (3-up section cards). +- Background `{colors.canvas}`, text `{colors.ink}`, padding `{spacing.lg}` 24 px, shape `{rounded.md}` 8 px (the `--geist-marketing-radius`). Carries Level 3 soft-stack shadow. + +**`card-marketing-large`** — the larger marketing card used for "compute model" / "AI Gateway" callouts. +- Background `{colors.canvas}`, text `{colors.ink}`, padding `{spacing.xl}`, shape `{rounded.lg}` 12 px. Carries Level 4 float-stack shadow. + +**`card-soft`** — the soft-tinted card used inside cluster groups (lighter than canvas-soft). +- Background `{colors.canvas-soft}`, text `{colors.ink}`, padding `{spacing.lg}`, shape `{rounded.md}`. + +**`template-card`** — the deploy-template card in the "Deploy your first app" grid. +- Background `{colors.canvas}`, text `{colors.ink}`, padding `{spacing.md}` 16 px, shape `{rounded.md}` 8 px. Hosts a 16:9 thumbnail at the top. + +**`code-editor-mockup`** — the dark code-preview surface inside marketing bands. +- Background `{colors.primary}`, text `{colors.on-primary}`, body in `{typography.code}` (13 px / Geist Mono), padding `{spacing.lg}` 24 px, shape `{rounded.md}` 8 px. + +**`pricing-card`** — the default pricing-tier card. +- Background `{colors.canvas}`, text `{colors.ink}`, padding `{spacing.xl}` 32 px, shape `{rounded.lg}` 12 px. Inside: tier name in `{typography.display-md}`, price in `{typography.display-xl}`, feature list in `{typography.body-md}` rows, CTA at the bottom. + +**`pricing-card-featured`** — the polarity-flipped "Pro" tier card. +- Background `{colors.primary}`, text `{colors.on-primary}`, same shape + padding as `pricing-card`. CTA inverts to `button-secondary-sm` (white pill on black card). + +### Inputs & Forms + +**`form-input`** — the canonical text input. +- Background `{colors.canvas}`, text `{colors.ink}`, 1 px solid `{colors.hairline}` border, body in `{typography.body-sm}` (14 px), padding `0px {spacing.sm}`, height 40 px (the brand's `--geist-form-height`), shape `{rounded.sm}` 6 px. + +**`form-input-sm`** — small-height variant (32 px tall) for tight forms. +- Same as `form-input` but height 32 px (the `--geist-form-small-height`). + +**`form-input-lg`** — large-height variant (48 px tall) for hero CTAs. +- Same as `form-input` but height 48 px (the `--geist-form-large-height`); body in `{typography.body-md}` 16 px. + +### Navigation + +**`nav-bar`** — the sticky top nav. +- Background `{colors.canvas}`, text `{colors.ink}`, height 64 px (the brand's `--header-height`), padding `{spacing.sm} {spacing.lg}`. Layout: logo left, link row centre, "Ask AI / Log In / Sign Up" cluster right. + +**`nav-link`** — the centred link row inside `nav-bar`. +- Text `{colors.body}`, set in `{typography.body-sm}`, padding `{spacing.xs} {spacing.sm}`, shape `{rounded.full}` (ghost pill — visible only on hover or active, but the radius is documented). + +**`footer`** — the bottom 4-column nav. +- Background `{colors.canvas}`, text `{colors.body}`, padding `{spacing.4xl} {spacing.lg}`. Eyebrow column labels in `{typography.caption-mono}` (uppercase mono effect); link rows in `{typography.body-sm}`. + +### Signature Components + +**`hero-band`** — the white hero with the mesh gradient backdrop. +- Background `{colors.canvas}` (or `{colors.canvas-soft}` on some surfaces), text `{colors.ink}`, padding `{spacing.4xl} {spacing.lg}`. Inside: a small mono badge above the headline, the headline in `{typography.display-xl}` (sentence-case, period-terminated), a body lead in `{typography.body-lg}`, then a CTA row with `button-primary` + `button-secondary`. The mesh gradient sits behind, scaled to occupy roughly the top half of the band. + +**`feature-mesh-band`** — the secondary section that hosts a mesh-gradient atmospheric backdrop with feature copy on top. +- Background `{colors.canvas}`, text `{colors.ink}`, padding `{spacing.5xl} {spacing.lg}`. Section headline in `{typography.display-lg}`; supporting body in `{typography.body-md}`. + +**`showcase-band-light`** — a soft-canvas section ("Deploy your first app in seconds"). +- Background `{colors.canvas-soft}`, text `{colors.ink}`, padding `{spacing.5xl} {spacing.lg}`. + +**`showcase-band-dark`** — the polarity-flipped dark band ("A compute model for all workloads"). +- Background `{colors.primary}`, text `{colors.on-primary}`, padding `{spacing.5xl} {spacing.lg}`. Section headline in `{typography.display-lg}` (white on black). Often contains a `code-editor-mockup` flush with the band. + +**`logo-strip`** — the customer-logo wrapping row near the top of the page. +- Background `{colors.canvas}`, text `{colors.body}`, padding `{spacing.lg} {spacing.xl}`. Logos rendered as monochrome SVGs at consistent height. + +**`badge-secondary`** — the small inline metadata pill ("New", "Beta", "Live"). +- Background `{colors.canvas-soft}`, text `{colors.body}`, body in `{typography.caption}`, padding `0px {spacing.xs}`, shape `{rounded.full}`. + +**`banner-marketing`** — the "Introducing X" announcement pill at the top of pages. +- Background `{colors.canvas-soft}`, text `{colors.body}`, body in `{typography.body-sm}`, padding `{spacing.xs} {spacing.sm}`, shape `{rounded.full}`. + +**`link-inline`** — body-copy inline links. +- Text `{colors.link}` (`#0070f3`), body in `{typography.body-md}`, underlined. + +### Examples (illustrative) + +> Auto-derived kit-mirror demonstration surfaces (`scripts/derive-examples-block.mjs`). Each `ex-*` entry references brand-native primitives so downstream consumers (`/preview-design`, `/generate-kit`) re-skin the same 10 surfaces consistently. `TO_FILL` markers indicate missing primitives — resolve in the LLM judgment pass. + +**`ex-pricing-tier`** — Default Pricing tier card. Re-uses feature-card chrome with brand canvas-soft surface. +- Properties: `backgroundColor`, `textColor`, `borderColor`, `rounded`, `padding` + +**`ex-pricing-tier-featured`** — Featured/highlighted tier — polarity-flipped surface (dark fill + light text in light mode, light fill + dark text in dark mode). +- Properties: `backgroundColor`, `textColor`, `rounded`, `padding` + +**`ex-product-selector`** — What's Included summary card — re-purposed for SaaS / B2B verticals (NOT a literal product gallery). +- Properties: `backgroundColor`, `rounded`, `padding` + +**`ex-cart-drawer`** — Subscription summary — re-purposed for SaaS / B2B (line items per add-on, not literal cart). +- Properties: `backgroundColor`, `rounded`, `padding`, `item-divider` + +**`ex-app-shell-row`** — Sidebar nav row inside the App Shell example. Active state uses brand primary as the indicator. +- Properties: `backgroundColor`, `activeIndicator`, `rounded`, `padding` + +**`ex-data-table-cell`** — Default data-table th + td chrome. Header uses mono-caps eyebrow typography; body uses body-sm. +- Properties: `headerBackground`, `headerTypography`, `bodyTypography`, `cellPadding`, `rowBorder` + +**`ex-auth-form-card`** — Sign-in / sign-up card. Re-uses feature-card chrome with text-input primitives inside. +- Properties: `backgroundColor`, `rounded`, `padding` + +**`ex-modal-card`** — Modal dialog surface — same chrome as feature-card with elevated shadow. +- Properties: `backgroundColor`, `rounded`, `padding` + +**`ex-empty-state-card`** — Empty-state illustration frame. +- Properties: `backgroundColor`, `rounded`, `padding`, `captionTypography` + +**`ex-toast`** — Toast notification surface — feature-card shape + medium shadow. +- Properties: `backgroundColor`, `rounded`, `padding`, `typography` + + +## Do's and Don'ts + +### Do +- Reserve `{colors.primary}` (`#171717`) for primary CTAs across the page. Black ink IS the conversion target. +- Use `{rounded.pill}` 100 px for every marketing-scale CTA and `{rounded.sm}` 6 px for nav-scale buttons. The two pill scales coexist deliberately. +- Set every headline in `{typography.display-*}` weight 600, sentence-case, often period-terminated. Aggressive negative tracking is part of the voice. +- Use the brand mesh gradient as atmospheric decoration at hero scale only — never miniaturise it to an icon, never reduce to a single colour. +- Layer stacked shadows (multiple small offsets with inset hairline) rather than single heavy drops. The brand's elevation is calmer than Material. +- Cycle page surfaces in `{colors.canvas-soft}` → `{colors.canvas}` → `{colors.primary}` polarity-flipped bands; the dark band IS the depth cue. +- Set every code block and technical eyebrow in `{typography.code}` / `{typography.caption-mono}`. Mono is the voice of the platform. + +### Don't +- Don't introduce a sixth accent colour. The brand operates with ink + gray + the four-pair gradient palette; new accents flatten the voice. +- Don't render headlines in all-caps. Sentence-case + negative tracking is non-negotiable. +- Don't drop a single heavy drop-shadow on cards. The brand's elevation is built from stacked small offsets + inset hairline rings. +- Don't render the brand gradient at icon scale or in a single-colour reduced form. The gradient lives at hero scale only. +- Don't promote the geometric sans to weight 700. The brand's display ceiling is 600. +- Don't pair the marketing 100-px pill CTA shape with the 6-px nav radius on the same screen — pick a scale and stay there. +- Don't set body paragraphs in the mono face. The mono is for code + technical labels only. diff --git a/design_rule.md b/design_rule.md index b1b8c4e..74ef70a 100644 --- a/design_rule.md +++ b/design_rule.md @@ -10,9 +10,17 @@ * **Green Accent**: 블루 대신 짙은 그린(`#1E5149`)을 포인트 컬러로 사용하여 차분한 전문성을 강조합니다. ### 2. 타이포그래피 (Typography) -* **Font Family**: `Pretendard` (전역 적용) +* **Font Family**: `Pretendard Variable`, `Pretendard` (전역 적용) +* **Base Font Size**: 기본 텍스트 크기는 **16px**로 설정합니다. * **Letter Spacing**: `-0.02em` (약 -2%) 적용. 자간을 좁게 설정하여 밀도 있고 세련된 가독성을 확보합니다. -* **Weights**: 400(Regular), 500(Medium), 600(SemiBold), 700(Bold), 800(ExtraBold). +* **Weights**: 400(Regular), 500(Medium), 600(SemiBold), 700(Bold), 800(ExtraBold), 900(Black). +* **Standard Scale**: + * **XS (12px)**: 보조 텍스트, 작은 라벨 + * **SM (14px)**: 일반 항목 라벨, 필터 텍스트 + * **Base (16px)**: 메인 데이터 내용, 테이블 셀, 상세 정보 값 + * **MD (19px)**: 섹션 제목, 강조 문구 + * **LG (23px)**: 주요 페이지 타이틀 + * **XL (29px)**: 핵심 통계 수치 (KPI) ### 3. 컬러 팔레트 (Color Palette) * **Point Color**: `#1E5149` (Deep Green) - 강조, 활성화 상태, 주요 액션 버튼. diff --git a/src/main.ts b/src/main.ts index d0bd1a9..c10ab10 100644 --- a/src/main.ts +++ b/src/main.ts @@ -36,24 +36,9 @@ function refreshView(tab?: string) { const isServerTab = activeTab === '서버'; mainContent.innerHTML = ` -
-
- - -
-
-
+
`; - // 이벤트 바인딩 - mainContent.querySelectorAll('.mode-toggle-btn').forEach(btn => { - btn.addEventListener('click', () => { - const mode = (btn as HTMLElement).getAttribute('data-mode') as any; - state.viewMode = mode; - refreshView(); - }); - }); - const viewBody = document.getElementById('view-body')!; if (state.viewMode === 'location') { renderLocationView(viewBody); diff --git a/src/styles/common.css b/src/styles/common.css index 2ea9c52..b7e72b6 100644 --- a/src/styles/common.css +++ b/src/styles/common.css @@ -45,7 +45,15 @@ --white: #FFFFFF; --danger: var(--color-red); --success: var(--color-green); - --header-height: auto; + --header-height: 101px; + + /* --- Global Typography Scale --- */ + --fs-xs: 12px; + --fs-sm: 14px; + --fs-base: 16px; + --fs-md: 19px; + --fs-lg: 23px; + --fs-xl: 29px; } * { @@ -60,8 +68,10 @@ body { color: var(--text-main); background-color: var(--bg-color); line-height: 1.5; - font-size: 19px; - overflow: hidden; + font-size: var(--fs-base); + height: 100vh; + width: 100vw; + overflow: hidden; /* 강제 스크롤 제거 */ } .app-layout { @@ -69,6 +79,7 @@ body { flex-direction: column; height: 100vh; width: 100%; + overflow: hidden; } /* --- Header --- */ @@ -76,7 +87,7 @@ body { background-color: var(--white); border-bottom: 1px solid var(--border-color); z-index: 100; - height: auto; + height: var(--header-height); flex-shrink: 0; } @@ -117,24 +128,14 @@ body { .gnb-trigger:hover { color: var(--text-main); } .gnb-trigger.active { color: var(--primary-color); font-weight: 900; border-bottom-color: var(--primary-color); background-color: var(--primary-lv-0); } -.header-actions { display: flex; align-items: center; gap: 1rem; } -.role-switcher { display: flex; align-items: center; gap: 0.75rem; padding: 0 0.75rem; border-right: 1px solid var(--border-color); height: 24px; } -.role-label { font-size: 15px; font-weight: 800; color: var(--text-muted); } -.role-label.active { color: var(--primary-color); } -.switch { position: relative; display: inline-block; width: 34px; height: 18px; } -.switch input { opacity: 0; width: 0; height: 0; } -.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 34px; } -.slider:before { position: absolute; content: ""; height: 12px; width: 12px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; } -input:checked + .slider { background-color: var(--color-orange); } -input:checked + .slider:before { transform: translateX(16px); } - /* --- Layout Content --- */ .content-area { flex: 1; - padding: 1.25rem 2rem 0; - overflow: hidden; + padding: 0; /* 모든 뷰에서 공간 꽉 채우기 */ display: flex; flex-direction: column; + overflow: hidden; + height: 100%; } .view-container { @@ -143,43 +144,43 @@ input:checked + .slider:before { transform: translateX(16px); } display: flex; flex-direction: column; overflow: hidden; + height: 100%; } -.view-content-wrapper { - flex: 1; - overflow-y: auto; - padding-bottom: 2rem; +/* --- View Toggle (Minimal Line-based) --- */ +.view-toggle-container { + display: flex; + justify-content: flex-start; + align-items: center; } -/* --- View Toggle --- */ -.view-toggle-container { margin-bottom: 1rem; display: flex; justify-content: flex-start; } -.view-toggle { display: inline-flex; background-color: var(--primary-lv-0); padding: 4px; border-radius: 8px; border: 1px solid var(--border-color); } -.toggle-btn { padding: 6px 16px; font-size: 17px; font-weight: 700; color: var(--text-muted); background: none; border: none; border-radius: 6px; cursor: pointer; } -.toggle-btn.active { background-color: var(--white); color: var(--primary-color); box-shadow: 0 2px 4px rgba(0,0,0,0.05); } +.view-toggle { + display: inline-flex; + background: #f1f5f9; + padding: 0.25rem; + border: 1px solid var(--border-color); + gap: 0.25rem; + border-radius: 8px; +} -/* --- System Status List (Docker Style) --- */ -.system-status-list { display: flex; flex-direction: column; gap: 0.5rem; } -.system-list-header { display: flex; align-items: center; padding: 0.75rem 1.25rem; background-color: var(--bg-light); border-bottom: 1px solid var(--border-color); font-size: 15px; font-weight: 800; color: var(--text-muted); text-transform: uppercase; } -.system-row { display: flex; align-items: center; padding: 1rem 1.25rem; background-color: var(--white); border: 1px solid var(--border-color); border-radius: 6px; transition: all 0.2s; } -.system-row:hover { border-color: var(--primary-lv-3); box-shadow: 0 4px 12px rgba(0,0,0,0.03); } -.col-status { width: 100px; display: flex; align-items: center; gap: 0.5rem; } -.col-info { flex: 1.5; } -.col-network { flex: 1; } -.col-remote { flex: 1; display: flex; align-items: center; gap: 0.5rem; } -.col-traffic { flex: 1.2; } -.col-actions { width: 120px; display: flex; justify-content: flex-end; } -.status-dot { width: 10px; height: 10px; border-radius: 50%; } -.status-dot.online { background-color: var(--success); box-shadow: 0 0 6px var(--success); } -.status-text { font-size: 15px; font-weight: 700; color: var(--success); } -.asset-primary { font-weight: 800; font-size: 19px; } -.asset-secondary { font-size: 16px; color: var(--text-muted); } -.ip-address { font-weight: 700; font-family: monospace; color: var(--primary-color); } -.traffic-mini-chart { display: flex; flex-direction: column; gap: 4px; } -.traffic-info { display: flex; justify-content: space-between; font-size: 15px; } -.progress-bg { height: 4px; background: var(--primary-lv-0); border-radius: 2px; overflow: hidden; } -.progress-fill { height: 100%; background: var(--primary-color); } -.icon-btn { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 4px; border: 1px solid var(--border-color); background: var(--white); color: var(--text-muted); cursor: pointer; } -.icon-btn:hover { background-color: var(--primary-light); border-color: var(--primary-color); color: var(--primary-color); } +.toggle-btn { + padding: 0.4rem 1rem; + border: none; + background: transparent; + font-size: 14px; + font-weight: 700; + color: var(--text-muted); + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; + border-radius: 6px; +} + +.toggle-btn.active { + background: var(--white); + color: var(--primary-color); + box-shadow: 0 1px 2px rgba(0,0,0,0.05); +} /* --- Footer --- */ .main-footer { @@ -194,118 +195,45 @@ input:checked + .slider:before { transform: translateX(16px); } } .main-footer p { - font-family: 'Pretendard Variable', Pretendard, sans-serif; - font-size: 1rem; - font-weight: 400; - line-height: 1.25rem; - letter-spacing: -0.0175rem; + font-size: var(--fs-xs); color: #777777; - user-select: none; - pointer-events: all; - -webkit-user-drag: none; margin: 0; - padding: 0; - box-sizing: border-box; -} - -.hidden { - display: none !important; -} - -.text-nowrap { - white-space: nowrap; } /* --- Utility Styles --- */ -.btn { display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem; padding: 0 0.8rem; font-size: 16px; font-weight: 700; border-radius: 4px; cursor: pointer; height: 28px; } -.btn-primary { background-color: var(--primary-color); color: var(--white); border: none; } -.btn-outline { background-color: transparent; color: var(--text-muted); border: 1px solid var(--border-color); } - -.badge { - padding: 2px 6px; - border-radius: 4px; - font-size: 21px; - font-weight: 800; +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + padding: 0 1rem; + font-size: var(--fs-sm); + font-weight: 700; + border-radius: 4px; + cursor: pointer; + height: 34px; + transition: all 0.2s; + border: 1px solid transparent; white-space: nowrap; } -.badge-primary { - background-color: var(--primary-color); - color: white; -} +.btn-primary { background-color: var(--primary-color); color: var(--white); } +.btn-primary:hover { background-color: var(--primary-hover); } -.badge-muted { - background-color: #9CA3AF; - color: white; -} +.btn-outline { background-color: var(--white); color: var(--text-muted); border: 1px solid var(--border-color); } +.btn-outline:hover { border-color: var(--primary-color); color: var(--primary-color); } -.badge-light { - background: var(--bg-color); - color: var(--text-muted); - border: 1px solid var(--border-color); -} +.btn-sm { height: 28px; padding: 0 0.75rem; font-size: var(--fs-xs); } + +.badge { padding: 2px 8px; border-radius: 4px; font-size: var(--fs-xs); font-weight: 800; white-space: nowrap; } +.badge-primary { background-color: var(--primary-color); color: white; } /* PC 성능 등급 뱃지 컬러 스타일 */ -.badge.b-purple { - background-color: #EDE9FE; - color: #7C3AED; - border: 1px solid #DDD6FE; - font-size: 15px; - padding: 2px 6px; -} -.badge.b-primary { - background-color: #DBEAFE; - color: #1D4ED8; - border: 1px solid #BFDBFE; - font-size: 15px; - padding: 2px 6px; -} -.badge.b-green { - background-color: #D1FAE5; - color: #047857; - border: 1px solid #A7F3D0; - font-size: 15px; - padding: 2px 6px; -} -.badge.b-yellow { - background-color: #FEF3C7; - color: #D97706; - border: 1px solid #FDE68A; - font-size: 15px; - padding: 2px 6px; -} +.badge.b-purple { background-color: #EDE9FE; color: #7C3AED; border: 1px solid #DDD6FE; } +.badge.b-primary { background-color: #DBEAFE; color: #1D4ED8; border: 1px solid #BFDBFE; } +.badge.b-green { background-color: #D1FAE5; color: #047857; border: 1px solid #A7F3D0; } +.badge.b-yellow { background-color: #FEF3C7; color: #D97706; border: 1px solid #FDE68A; } -.text-tag { - color: var(--text-muted); - font-size: 21px; - padding: 1px 5px; - border: 1px solid var(--border-color); - border-radius: 3px; - background-color: var(--bg-light); -} - -.font-bold { - font-weight: 800; -} - -/* --- Responsive Design (Tablet & Mobile) --- */ -@media (max-width: 1200px) { - .header-container { gap: 0.75rem; padding: 0 1rem; } - .brand h1 { font-size: 1.33rem; } - .brand h1 .sub-title { font-size: 1rem; } -} - -@media (max-width: 992px) { - .main-header { height: auto; padding: 0.5rem 0; } - .header-container { flex-direction: column; align-items: flex-start; gap: 0.5rem; } - .integrated-nav { width: 100%; justify-content: flex-start; border-top: 1px solid var(--border-color); padding-top: 0.5rem; } - .header-actions { width: 100%; justify-content: flex-end; padding-top: 0.5rem; } - .content-area { padding: 0 1rem; } -} - -.gnb-trigger.admin-trigger { - color: var(--text-muted); - border-left: 1px solid var(--border-color); - margin-left: 1rem; - padding-left: 1.5rem; -} +.sidebar-title { margin: 0; font-size: var(--fs-md); font-weight: 800; color: var(--primary-color); } +.empty-state { padding: 3rem 1rem; color: var(--text-muted); text-align: center; font-size: var(--fs-base); } +.hidden { display: none !important; } diff --git a/src/styles/dashboard.css b/src/styles/dashboard.css index fc43194..fea424c 100644 --- a/src/styles/dashboard.css +++ b/src/styles/dashboard.css @@ -13,534 +13,88 @@ .dashboard-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 1.5rem; - margin-bottom: 2rem; + gap: 0; + border-top: 1px solid var(--border-color); } -/* Premium Executive Divider-based Style (Line-based Division) */ .dashboard-card, .stat-card { - background: transparent; - backdrop-filter: none; - -webkit-backdrop-filter: none; + background: #fff; border: none; + border-right: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color); box-shadow: none; border-radius: 0; - padding: 1.5rem 0.5rem; + padding: 1.5rem; display: flex; flex-direction: column; - transition: opacity 0.2s ease; -} - -.dashboard-card:hover, .stat-card:hover { - transform: none; - box-shadow: none; - opacity: 0.85; -} - -.dashboard-layout-2col { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 1.5rem; -} - -.dashboard-layout-3col { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 1.5rem; -} - -.dashboard-card { - min-height: 380px; } .dashboard-stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); - gap: 1.5rem; - margin-bottom: 2rem; + gap: 0; + border-top: 1px solid var(--border-color); } -/* Premium KPI Value Styling */ .stat-value { - font-size: 3.21rem; + font-size: var(--fs-xl); font-weight: 900; - background: linear-gradient(135deg, #1E5149 0%, #3B82F6 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; + color: var(--text-main); margin-top: 0.5rem; display: flex; align-items: center; gap: 0.5rem; } -.stat-value-danger { - background: linear-gradient(135deg, #E11D48 0%, #F59E0B 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; -} - .stat-label { - font-size: 1.81rem; + font-size: var(--fs-base); color: var(--text-muted); font-weight: 800; text-transform: uppercase; - letter-spacing: 0.05em; } -.stat-icon { - width: 48px; - height: 48px; - border-radius: 12px; - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 1rem; -} - -.icon-blue { background: rgba(59, 130, 246, 0.1); color: #3B82F6; } -.icon-green { background: rgba(30, 81, 73, 0.1); color: #1E5149; } -.icon-red { background: rgba(225, 29, 72, 0.1); color: #E11D48; } -.icon-yellow { background: rgba(245, 158, 11, 0.1); color: #F59E0B; } - .table-premium { background: white; - border-radius: 12px; - box-shadow: 0 4px 15px rgba(0,0,0,0.05); + border: none; + box-shadow: none; + border-radius: 0; + border-bottom: 1px solid var(--border-color); overflow: hidden; } -.table-premium table { - width: 100%; - border-collapse: collapse; -} - .table-premium th { background: #F8FAFC; color: #475569; font-weight: 800; - padding: 1rem; - text-transform: uppercase; - font-size: 1.28rem; - letter-spacing: 0.05em; + padding: 0.75rem 1rem; + font-size: var(--fs-sm); + border-bottom: 2px solid var(--border-color); } .table-premium td { - padding: 1rem; + padding: 0.75rem 1rem; border-bottom: 1px solid #E2E8F0; color: #1E293B; - font-size: 21px; -} - -.table-premium tr:hover td { - background: #F1F5F9; -} - -/* --- Slider/Carousel Specific Styles --- */ -.dashboard-header-wrapper { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 2rem; -} - -.slider-controls { - display: flex; - align-items: center; - gap: 1rem; -} - -.slider-nav-btn { - background: white; - border: 1px solid var(--border-color); - box-shadow: 0 2px 8px rgba(0,0,0,0.05); - border-radius: 50%; - width: 36px; - height: 36px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - color: var(--text-main); - transition: all 0.2s; -} - -.slider-nav-btn:hover { - background: var(--primary-color); - color: white; - border-color: var(--primary-color); -} - -.slider-nav-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.page-info { - font-size: 1.28rem; - color: var(--text-muted); - font-weight: 700; -} - -.page-btns button { - padding: 0.3rem 0.75rem; - border: 1px solid var(--border-color); - background: var(--white); - border-radius: 4px; - font-size: 1.28rem; - cursor: pointer; -} - -.page-btns button:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.slider-indicator { - font-weight: 800; - color: var(--text-muted); - font-size: 1.88rem; -} - -.dashboard-slider-viewport { - width: 100%; - overflow: hidden; - padding: 0.5rem 0; -} - -.dashboard-slider-track { - display: flex; - transition: transform 0.5s cubic-bezier(0.25, 0.8, 0.25, 1); - width: 400%; /* For 4 pages */ -} - -.dashboard-slide { - width: 25%; /* 100% / 4 pages */ - flex-shrink: 0; - padding: 0 2px; /* Slight padding to avoid cutting off box-shadows */ - height: calc(100vh - 150px); - min-height: 520px; - display: flex; - flex-direction: column; - box-sizing: border-box; -} - -/* --- Location View Styles --- */ -.location-layout { - display: grid; - grid-template-columns: 1.2fr 1fr; - gap: 2rem; - height: calc(100vh - 180px); -} - -.map-section, .asset-section { - display: flex; - flex-direction: column; -} - -.section-title { - font-size: 1.5rem; - font-weight: 800; - margin-bottom: 1rem; - color: var(--text-main); - display: flex; - align-items: center; -} - -.map-wrapper { - flex: 1; - background: #f8fafc; - box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.05); -} - -.location-box { - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); - user-select: none; -} - -.location-box:hover { - background: rgba(30, 81, 73, 0.2) !important; - transform: scale(1.02); - z-index: 10; -} - -.location-box:active { - transform: scale(0.98); -} - -.asset-section .table-container { - flex: 1; - overflow-y: auto; -} - -.status-tag { - display: inline-block; - padding: 0.25rem 0.625rem; - border-radius: 9999px; - font-size: 1rem; - font-weight: 700; - background: #ecfdf5; - color: #059669; - border: 1px solid #d1fae5; -} - -.view-toggle-btn:hover { - border-color: var(--primary-color) !important; - color: var(--primary-color) !important; -} - -.view-toggle-btn.active:hover { - color: white !important; -} - -/* --- View Toggle Header --- */ -.view-header { - padding: 0.5rem 1.5rem; - background: var(--white); - border-bottom: 1px solid var(--border-color); - display: flex; - align-items: center; - justify-content: flex-start; - gap: 1rem; -} - -.view-toggle-container { - display: flex; - background: #f1f5f9; - padding: 0.25rem; - border-radius: 8px; - gap: 0.25rem; -} - -.mode-toggle-btn { - padding: 0.5rem 1rem; - border: none; - background: transparent; - border-radius: 6px; - font-size: 1.08rem; - font-weight: 700; - color: var(--text-muted); - cursor: pointer; - transition: all 0.2s ease; -} - -.mode-toggle-btn:hover { - color: var(--text-main); -} - -.mode-toggle-btn.active { - background: var(--white); - color: var(--primary-color); - box-shadow: 0 1px 3px rgba(0,0,0,0.1); -} - -/* --- Enhanced Location View --- */ -.location-view-wrapper { - display: flex; - flex-direction: column; - height: calc(100vh - 120px); -} - -.location-filter-bar { - padding: 1rem 1.5rem; - background: var(--white); - border-bottom: 1px solid var(--border-color); - display: flex; - align-items: center; - gap: 2rem; -} - -.filter-group { - display: flex; - align-items: center; - gap: 0.75rem; -} - -.filter-group label { - font-size: 1.08rem; - font-weight: 800; - color: var(--text-main); -} - -.filter-group select { - padding: 0.4rem 0.75rem; - border: 1px solid var(--border-color); - border-radius: 6px; - font-size: 1.08rem; - color: var(--text-main); - background: var(--white); - min-width: 140px; -} - -.map-pagination { - margin-left: auto; - display: flex; - align-items: center; - gap: 1rem; -} - -.location-main-content { - flex: 1; - display: grid; - grid-template-columns: 1.4fr 1fr; - gap: 1.5rem; - padding: 1.5rem; - overflow: hidden; -} - -.map-container-section { - display: flex; - flex-direction: column; - overflow: auto; -} - -.location-box-point { - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s ease; -} - -.box-label-text { - font-size: 0.87rem; - font-weight: 900; - color: var(--primary-color); - pointer-events: none; - text-shadow: 0 0 2px white; -} - -.asset-list-section { - background: var(--white); - border-radius: 12px; - border: 1px solid var(--border-color); - display: flex; - flex-direction: column; - overflow: hidden; -} - -.asset-list-section .section-header { - padding: 1rem 1.25rem; - border-bottom: 1px solid var(--border-color); - background: #f8fafc; -} - -.asset-list-section h4 { - margin: 0; - font-size: 1.25rem; - color: var(--text-main); -} - -.mini-table-wrapper { - flex: 1; - overflow-y: auto; -} - -.compact-table { - width: 100%; - border-collapse: collapse; -} - -.compact-table th { - position: sticky; - top: 0; - background: var(--white); - padding: 0.75rem 1rem; - text-align: left; - font-size: 1rem; - font-weight: 800; - color: var(--text-muted); - border-bottom: 1px solid var(--border-color); -} - -.compact-table td { - padding: 0.75rem 1rem; - font-size: 1.08rem; - border-bottom: 1px solid #f1f5f9; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 150px; -} - -.compact-table tr.clickable-row:hover { - background: #f1f5f9; - cursor: pointer; -} - -/* --- Asset Detail Sidebar (LocationView) --- */ -.asset-detail-sidebar { - padding-top: 1rem; - background: var(--white); - height: 100%; - overflow-y: auto; -} - -.detail-section { - margin-bottom: 20px; - padding: 0 1.25rem; -} - -.detail-section-title { - font-size: 17px; - font-weight: 800; - color: var(--primary-color); - border-bottom: 1px solid var(--border-color); - padding-bottom: 6px; - margin-bottom: 12px; -} - -.detail-grid { - display: grid; - grid-template-columns: repeat(2, minmax(80px, auto) 1fr); - gap: 8px 16px; -} - -.detail-label { - font-size: 16px; - color: var(--text-muted); - font-weight: 700; - display: flex; - align-items: center; -} - -.detail-value { - font-size: 19px; - color: var(--text-main); - font-weight: 600; - word-break: break-all; - display: flex; - align-items: center; -} - -.detail-header-actions { - display: flex; - align-items: center; - gap: 8px; - width: 100%; -} - -.detail-header-title { - flex: 1; - font-size: 1.27rem; - font-weight: 800; + font-size: var(--fs-base); } /* --- System Dashboard Stats Row (ListFactory) --- */ .dashboard-stats-row { display: grid; grid-template-columns: 1fr 1.5fr 1.5fr; - gap: 2rem; + gap: 0; border-bottom: 1px solid var(--border-color); - padding-bottom: 1.25rem; + padding: 0; margin-bottom: 1rem; flex-shrink: 0; + background: #fff; } .stat-group-item { min-width: 0; display: flex; flex-direction: column; + padding: 1.5rem; } .stat-group-item.bordered { @@ -549,7 +103,7 @@ } .stat-group-item .stat-label { - font-size: 15px; + font-size: var(--fs-sm); font-weight: 600; color: var(--text-muted); margin-bottom: 0.25rem; @@ -557,16 +111,16 @@ } .stat-group-item .stat-value { - font-size: 37px; + font-size: var(--fs-xl); font-weight: 900; color: var(--text-main); line-height: 1.1; - background: none; - -webkit-text-fill-color: initial; + display: flex; + align-items: baseline; } .stat-group-item .stat-value span { - font-size: 17px; + font-size: var(--fs-sm); font-weight: 700; margin-left: 4px; color: var(--text-muted); @@ -575,13 +129,14 @@ .stat-group-item .stat-sub { display: flex; gap: 1rem; - font-size: 19px; + font-size: var(--fs-base); color: var(--text-muted); margin-top: 0.5rem; } .stat-group-item .stat-sub strong { - font-size: 24px; + font-size: var(--fs-md); + font-weight: 800; } .text-primary { @@ -597,25 +152,16 @@ } .stat-title { - font-size: 19px; + font-size: var(--fs-base); font-weight: 900; color: var(--text-main); white-space: nowrap; } -.stat-badges { - display: flex; - gap: 4px; - flex-wrap: wrap; - justify-content: flex-end; -} - .detail-stat-body { display: flex; flex-direction: column; - gap: 0.4rem; - font-size: 17px; - color: var(--text-muted); + gap: 0.5rem; } .loc-summary { @@ -624,9 +170,15 @@ flex-wrap: wrap; } +.loc-summary span { + font-size: var(--fs-sm); + color: var(--text-muted); +} + .loc-summary span strong { color: var(--text-main); - font-size: 19px; + font-size: var(--fs-base); + font-weight: 800; } .type-summary { @@ -641,11 +193,14 @@ .type-summary span { cursor: help; + font-size: var(--fs-xs); + color: var(--text-muted); } .type-summary span strong { color: var(--text-main); - font-size: 19px; + font-size: var(--fs-sm); + font-weight: 800; } .text-danger { @@ -653,3 +208,166 @@ font-weight: 800; } +/* --- Location View (Strict Zero-Scroll Layout) --- */ +.location-view-wrapper { + display: flex; + flex-direction: column; + height: 100%; /* 부모(view-container)의 100% 강제 */ + width: 100%; + background: var(--white); + overflow: hidden; +} + +.location-filter-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 1.5rem; + border-bottom: 1px solid var(--border-color); + background: var(--white); + flex-shrink: 0; +} + +.location-main-content { + flex: 1; + display: grid; + grid-template-columns: 2fr 1fr; /* Default: Very wide screens */ + background: var(--white); + overflow: hidden; + align-items: stretch; + min-height: 0; +} + +/* --- Responsive Layout for Location View --- */ +@media (max-width: 1600px) { + .location-main-content { + grid-template-columns: 1.5fr 1fr; /* Normal Desktops */ + } +} + +@media (max-width: 1200px) { + .location-main-content { + grid-template-columns: 1.2fr 1fr; /* Tablets / Small Laptops */ + } +} + +@media (max-width: 768px) { + .location-main-content { + grid-template-columns: 1fr; + grid-template-rows: auto 1fr; /* Stacked on mobile */ + overflow-y: auto; + } + + .map-container-section { + border-right: none; + border-bottom: 1px solid var(--border-color); + height: 350px; + } +} + +.map-container-section { + position: relative; + overflow: hidden; + border-right: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: center; + background: #f1f5f9; + height: 100%; +} + +.map-frame-wrapper { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.map-image { + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + object-fit: contain; /* 공간에 맞춰 자동 축소, 절대 넘치지 않음 */ + display: block; +} + +.map-overlay { + position: absolute; + pointer-events: none; +} + +.asset-list-section { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + background: var(--white); +} + +.section-header { + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--border-color); + background: #f8fafc; + flex-shrink: 0; +} + +.mini-table-wrapper { + flex: 1; + overflow-y: auto; + min-height: 0; +} + +/* --- Asset Details (Refined Typography) --- */ +.asset-detail-sidebar { + padding: 1rem 0; + height: 100%; + overflow-y: auto; +} + +.detail-section { + margin-bottom: 24px; + padding: 0 1.25rem; +} + +.detail-section-title { + font-size: var(--fs-xs); + font-weight: 800; + color: var(--primary-color); + border-bottom: 1px solid var(--border-color); + padding-bottom: 4px; + margin-bottom: 12px; + text-transform: uppercase; +} + +.detail-grid-2col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px 16px; +} + +.detail-item.full-width { + grid-column: span 2; +} + +.detail-label-sm { + font-size: var(--fs-xs); + color: var(--text-muted); + font-weight: 700; +} + +.detail-value-lg { + font-size: var(--fs-base); + color: var(--text-main); + font-weight: 600; + line-height: 1.3; +} + +.asset-code-title { + font-size: var(--fs-md); + font-weight: 900; + color: var(--text-main); +} diff --git a/src/styles/table.css b/src/styles/table.css index 96d6aa8..cb30ccb 100644 --- a/src/styles/table.css +++ b/src/styles/table.css @@ -119,7 +119,7 @@ table { } th, td { - padding: 0.8rem 1.2rem; + padding: 0.5rem 1rem; border-bottom: 1px solid var(--border-color); text-align: left; /* 기본은 좌측 정렬 */ white-space: nowrap; diff --git a/src/views/Dashboard/HwDashboard.ts b/src/views/Dashboard/HwDashboard.ts index 1b6c443..9748f9d 100644 --- a/src/views/Dashboard/HwDashboard.ts +++ b/src/views/Dashboard/HwDashboard.ts @@ -18,7 +18,7 @@ export function renderHwDashboard(container: HTMLElement) { // 2. 1페이지 매거진 리포트(제목바 제거, '| 제목' 미니멀리즘 스타일) HTML 빌드 container.innerHTML = ` -
+
diff --git a/src/views/List/ListFactory.ts b/src/views/List/ListFactory.ts index 0cc6a63..dd0c873 100644 --- a/src/views/List/ListFactory.ts +++ b/src/views/List/ListFactory.ts @@ -161,9 +161,11 @@ export interface ListViewConfig { } export function createListView(container: HTMLElement, config: ListViewConfig) { - // 1. 컨테이너 초기화 및 헤더 렌더링 + // 1. 컨테이너 초기화 및 헤더 렌더링 (서버 탭은 상단 공간 확보를 위해 헤더 생략) container.innerHTML = ''; - renderPageHeader(container, config.title); + if (config.title !== '서버') { + renderPageHeader(container, config.title); + } const fullList = config.dataSource(); let sortState: SortState = config.persistentSortState || { key: '', direction: 'asc' }; @@ -185,30 +187,29 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { (state as any).currentViewMode = 'system'; } - // 2. 뷰 전환 토글 버튼 생성 + // 2. 뷰 전환 토글 바 생성 (Unified Header Style) const toggleWrapper = document.createElement('div'); - toggleWrapper.className = 'view-toggle-container'; + toggleWrapper.className = 'location-filter-bar'; // Use unified class for the bar const showPcFlowBtn = config.title === 'PC'; toggleWrapper.innerHTML = ` -
-
- - -
-
- ${showPcFlowBtn ? ` - - - ` : ''} - ` : ''} + + +
+
+ ${showPcFlowBtn ? ` + -
+ + ` : ''} +
`; container.appendChild(toggleWrapper); @@ -486,10 +487,8 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { const htmlMap = document.getElementById('detail-html-map') as HTMLIFrameElement; const isHtmlMap = imgPath?.toLowerCase().endsWith('.html'); - // 좌표가 없으면 사진이 있어도 '정보 없음' 상태로 유도 (사용자 요청) if (imgPath && hasCoords) { if (isHtmlMap) { - // HTML 지도 처리 photo.style.display = 'none'; if (marker) marker.style.display = 'none'; if (overlayLayer) overlayLayer.innerHTML = ''; @@ -498,7 +497,6 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { htmlMap.style.display = 'block'; } } else { - // 일반 이미지 지도 처리 if (htmlMap) { htmlMap.src = ''; htmlMap.style.display = 'none'; @@ -568,7 +566,6 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { if (noPhoto) { noPhoto.classList.remove('hidden'); noPhoto.style.display = 'flex'; } } - // 이력 보기 버튼 클릭 이벤트 const flowLogsBtn = document.getElementById('btn-view-flow-logs'); if (flowLogsBtn) { flowLogsBtn.onclick = () => { @@ -597,15 +594,10 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { const currentYearMonth = `${currentYear}-${String(currentMonthNum).padStart(2, '0')}`; if (isPcView) { - // PC 뷰일 때: 해당월의 PC 유동 이력을 렌더링하고, 클릭 시 해당 자산 상세를 띄움 const recentTbody = document.getElementById('system-status-tbody'); if (!recentTbody) return; - const titleEl = document.getElementById('list-section-title'); - if (titleEl) { - titleEl.textContent = `🔄 PC 유동 이력 (${currentMonthNum}월)`; - } - + if (titleEl) titleEl.textContent = `🔄 PC 유동 이력 (${currentMonthNum}월)`; const logs = state.masterData.logs || []; const flowLogs = logs.filter((log: any) => { const details = log.details || ''; @@ -617,338 +609,88 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { } return details.includes('[불출]') || details.includes('[반납]') || details.includes('[입고]') || details.includes('[이동]') || details.includes('[이관]'); }); - - // 해당월(currentYearMonth)에 발생한 로그만 필터링 - const monthlyFlowLogs = flowLogs.filter((log: any) => { - const logDate = log.log_date || ''; - return logDate.startsWith(currentYearMonth); - }); - + const monthlyFlowLogs = flowLogs.filter((log: any) => (log.log_date || '').startsWith(currentYearMonth)); if (monthlyFlowLogs.length === 0) { recentTbody.innerHTML = `${currentMonthNum}월 유동 이력이 없습니다.`; } else { recentTbody.innerHTML = monthlyFlowLogs.map((log: any) => { const details = log.details || ''; - - let typeDisplay = '-'; - let userDisplay = '-'; - let targetUserDisplay = '-'; - let assetCodeDisplay = '-'; - let memoDisplay = '-'; - + let typeDisplay = '-'; let userDisplay = '-'; let targetUserDisplay = '-'; let assetCodeDisplay = '-'; let memoDisplay = '-'; try { const info = JSON.parse(details); - if (info.type === 'checkout') typeDisplay = 'checkout'; - else if (info.type === 'return') typeDisplay = 'return'; - else if (info.type === 'move') typeDisplay = 'move'; - - userDisplay = info.user || '-'; - targetUserDisplay = info.targetUser || '-'; - assetCodeDisplay = info.assetCode || '-'; - memoDisplay = info.memo || '-'; + typeDisplay = info.type; userDisplay = info.user || '-'; targetUserDisplay = info.targetUser || '-'; assetCodeDisplay = info.assetCode || '-'; memoDisplay = info.memo || '-'; } catch (e) { - // 하위 호환 파싱 (기존 텍스트형 로그) if (details.includes('[불출]')) typeDisplay = 'checkout'; else if (details.includes('[반납]') || details.includes('[입고]')) typeDisplay = 'return'; else if (details.includes('[이동]') || details.includes('[이관]')) typeDisplay = 'move'; - - const codeMatch = details.match(/PC-\d{6}-\d{4}|HW-PC-\d+/i); - if (codeMatch) assetCodeDisplay = codeMatch[0]; - - if (details.includes('[불출]')) { - const match1 = details.match(/\[불출\]\s*([^\s\(]+)\s*사원/); - if (match1) userDisplay = match1[1]; - else { - const match2 = details.match(/\[불출\]\s*([a-zA-Z가-힣]+)/); - userDisplay = match2 ? match2[1] : '-'; - } - } else if (details.includes('[반납]') || details.includes('[입고]')) { - const match1 = details.match(/\[(?:반납|입고)\]\s*([^\s\(]+)\s*사원/); - if (match1) userDisplay = match1[1]; - else { - const match2 = details.match(/\[(?:반납|입고)\]\s*([a-zA-Z가-힣]+)/); - userDisplay = match2 ? match2[1] : '-'; - } - } else if (details.includes('[이동]') || details.includes('[이관]')) { - const prefixWord = details.includes('[이동]') ? '\\[이동\\]' : '\\[이관\\]'; - const parts = details.split('➡️'); - if (parts.length === 2) { - const fromMatch = parts[0].match(new RegExp(`${prefixWord}\\s*([a-zA-Z가-힣]+)`)); - const toMatch = parts[1].match(/\s*([a-zA-Z가-힣]+)/); - if (fromMatch && toMatch) { - userDisplay = fromMatch[1]; - targetUserDisplay = toMatch[1]; - } - } - if (userDisplay === '-') { - const match1 = details.match(new RegExp(`${prefixWord}\\s*([^\s\(]+)\\s*사원`)); - if (match1) { - userDisplay = match1[1]; - } else { - const match2 = details.match(new RegExp(`${prefixWord}\\s*([a-zA-Z가-힣0-9_]+)`)); - userDisplay = match2 ? match2[1] : '-'; - } - } - } - - const cleanDetails = details.replace(/^\[(불출|반납|입고|이동|이관)\]\s*/, ''); - const memoParts = cleanDetails.split(' - '); - if (memoParts.length >= 2) { - memoDisplay = memoParts[memoParts.length - 1]; - } else { - if (cleanDetails.includes('지급') || cleanDetails.includes('반납') || cleanDetails.includes('이관')) { - memoDisplay = '-'; - } else { - memoDisplay = cleanDetails || '-'; - } - } + const codeMatch = details.match(/PC-\d{6}-\d{4}|HW-PC-\d+/i); if (codeMatch) assetCodeDisplay = codeMatch[0]; } - - // 구분 뱃지 생성 let badgeHtml = ''; - if (typeDisplay === 'checkout') { - badgeHtml = '불출'; - } else if (typeDisplay === 'return') { - badgeHtml = '입고'; - } else if (typeDisplay === 'move') { - badgeHtml = '이동'; - } else { - badgeHtml = '기타'; - } - + if (typeDisplay === 'checkout') badgeHtml = '불출'; + else if (typeDisplay === 'return') badgeHtml = '입고'; + else if (typeDisplay === 'move') badgeHtml = '이동'; + else badgeHtml = '기타'; return ` ${log.log_date || '-'} - ${log.log_user || '시스템'} + ${log.log_user || '시스템'} ${badgeHtml} - ${userDisplay} - ${targetUserDisplay} - ${assetCodeDisplay} - ${memoDisplay} - - `; + ${userDisplay} + ${targetUserDisplay} + ${assetCodeDisplay} + ${memoDisplay} + `; }).join(''); } } else { - // 기존의 자산 현황 목록 갱신 - let filtered = selectedLocation - ? fullList.filter(a => (a[ASSET_SCHEMA.LOCATION.key] || '미지정') === selectedLocation) - : fullList; + let filtered = selectedLocation ? fullList.filter(a => (a[ASSET_SCHEMA.LOCATION.key] || '미지정') === selectedLocation) : fullList; const currentDetailLocs = Array.from(new Set(filtered.map(a => a[ASSET_SCHEMA.LOC_DETAIL.key] || '미지정'))).sort(); if (selectedDetailLocation) filtered = filtered.filter(a => (a[ASSET_SCHEMA.LOC_DETAIL.key] || '미지정') === selectedDetailLocation); const finalDisplayList = (!selectedLocation && !selectedDetailLocation) ? filtered.slice(0, 10) : filtered; - const titleEl = document.getElementById('list-section-title'); if (titleEl) titleEl.textContent = selectedLocation ? `${selectedLocation} 자산 현황 (${finalDisplayList.length}대)` : '위치별 자산등록현황 (최근 등록)'; const selectEl = document.getElementById('select-detail-loc') as HTMLSelectElement; if (selectEl && !selectedDetailLocation) { selectEl.innerHTML = `` + currentDetailLocs.map(dl => ``).join(''); } - const tbody = document.getElementById('system-status-tbody'); if (tbody) { - tbody.innerHTML = finalDisplayList.length === 0 - ? `조회된 자산이 없습니다.` + tbody.innerHTML = finalDisplayList.length === 0 ? `조회된 자산이 없습니다.` : finalDisplayList.map(asset => { const purpose = asset[ASSET_SCHEMA.ASSET_PURPOSE.key] || ''; - const serviceTypeKey = (ASSET_SCHEMA as any).SERVICE_TYPE?.key || 'service_type'; - const serviceType = asset[serviceTypeKey] || '외부'; + const serviceType = asset.service_type || '외부'; const type = asset[ASSET_SCHEMA.ASSET_TYPE.key] || ''; const loc = asset[ASSET_SCHEMA.LOCATION.key] || ''; - - const labelColor = serviceType === '내부' ? '#94A3B8' : '#35635C'; - const managerMain = asset[ASSET_SCHEMA.MANAGER_MAIN.key] || '-'; - const managerSub = asset[ASSET_SCHEMA.MANAGER_SUB.key] || '-'; - - // [경고 로직] 외부 운영인데 서버PC이거나 IDC가 아닌 경우 - const isLocWarning = serviceType === '외부SW' && loc !== 'IDC'; - const isTypeWarning = serviceType === '외부SW' && type.toLowerCase().replace(/\s/g, '').includes('서버pc'); - const isWarning = isLocWarning || isTypeWarning; - const warningStyle = isWarning ? 'background-color: #FFF1F2; border-left: 3px solid #E11D48;' : ''; - - let warningReason = ''; - if (isLocWarning && isTypeWarning) warningReason = '위치/형식 부적절'; - else if (isLocWarning) warningReason = '위치 부적절'; - else if (isTypeWarning) warningReason = '형식 부적절'; - + const isWarning = serviceType === '외부SW' && (loc !== 'IDC' || type.toLowerCase().includes('서버pc')); return ` - - -
- ${serviceType} - ${isWarning ? `${warningReason}` : ''} -
- - ${purpose || '-'} - ${managerMain} - ${managerSub} - ${asset[ASSET_SCHEMA.LOC_DETAIL.key] || '-'} + + ${serviceType} + ${purpose || '-'} + ${asset[ASSET_SCHEMA.MANAGER_MAIN.key] || '-'} + ${asset[ASSET_SCHEMA.MANAGER_SUB.key] || '-'} + ${asset[ASSET_SCHEMA.LOC_DETAIL.key] || '-'} `; }).join(''); - tbody.querySelectorAll('.mini-row').forEach(row => { row.addEventListener('click', () => { - tbody.querySelectorAll('.mini-row').forEach(r => { - const rIsWarning = (r as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)'; // E11D48 - (r as HTMLElement).style.backgroundColor = rIsWarning ? '#FFF1F2' : 'transparent'; - }); - (row as HTMLElement).style.backgroundColor = '#EBF2F1'; // 선택 하이라이트 - const id = (row as HTMLElement).getAttribute('data-id'); - const asset = fullList.find(a => a.id === id); + tbody.querySelectorAll('.mini-row').forEach(r => (r as HTMLElement).style.backgroundColor = (r as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)' ? '#FFF1F2' : 'transparent'); + (row as HTMLElement).style.backgroundColor = '#EBF2F1'; + const asset = fullList.find(a => a.id === (row as HTMLElement).getAttribute('data-id')); if (asset) updateDetailPanel(asset); }); - row.addEventListener('mouseenter', () => { - if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') { - (row as HTMLElement).style.backgroundColor = '#F8FAFA'; - } - }); - row.addEventListener('mouseleave', () => { - const isWarning = (row as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)'; - if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') { - (row as HTMLElement).style.backgroundColor = isWarning ? '#FFF1F2' : 'transparent'; - } - }); - }); - } - } - }; - - const updateFlowLogsSection = () => { - if (!isPcView) return; - - // 사양 주의 장비 현황 (부족/오버스펙) 계산 및 바인딩 - const specMismatchTbody = document.getElementById('spec-mismatch-tbody'); - if (specMismatchTbody) { - // fullList 중 개인 PC 관련 장비 필터링 - const pcs = fullList.filter((a: any) => { - const type = a[ASSET_SCHEMA.ASSET_TYPE.key] || ''; - const job = a[ASSET_SCHEMA.USER_POSITION.key] || ''; - const status = a[ASSET_SCHEMA.HW_STATUS.key] || ''; - const user = a[ASSET_SCHEMA.CURRENT_USER.key] || ''; - - // 운영 중이고 사용자가 할당되어 있으며, 직무가 재고PC가 아닌 실사용 기기 대상 - return job !== '재고PC' && status === '운영' && user.trim() !== ''; - }); - - // 직무별 평균 점수 산출 - const jobScores: Record = {}; - pcs.forEach((pc: any) => { - const job = pc[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; - const cpu = pc[ASSET_SCHEMA.CPU.key] || ''; - const ram = pc[ASSET_SCHEMA.RAM.key] || ''; - const gpu = pc[ASSET_SCHEMA.GPU.key] || ''; - const pDate = pc[ASSET_SCHEMA.PURCHASE_DATE.key] || ''; - const score = calculatePcScoreDeductive(cpu, ram, gpu, pDate); - pc['_pc_score'] = score; - - if (!jobScores[job]) jobScores[job] = { totalScore: 0, count: 0, avg: 0 }; - jobScores[job].totalScore += score; - jobScores[job].count += 1; - }); - - Object.keys(jobScores).forEach(job => { - jobScores[job].avg = jobScores[job].count > 0 ? jobScores[job].totalScore / jobScores[job].count : 0; - }); - - // 기준 대비 사양 부족/오버스펙 분류 - const criticalPcList: any[] = []; - pcs.forEach((pc: any) => { - const job = pc[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; - const score = pc['_pc_score']; - const avg = jobScores[job].avg; - - if (avg > 0) { - if (score < avg * 0.6) { - pc['_spec_status'] = '사양 부족'; - criticalPcList.push(pc); - } else if (score > avg * 1.5) { - pc['_spec_status'] = '오버스펙'; - criticalPcList.push(pc); - } - } - }); - - // 정렬: 직무 평균 대비 사양 부족이 심한 순(비율이 낮은 순)으로 정렬 - criticalPcList.sort((a: any, b: any) => { - const jobA = a[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; - const jobB = b[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; - const ratioA = jobScores[jobA].avg > 0 ? a['_pc_score'] / jobScores[jobA].avg : 1; - const ratioB = jobScores[jobB].avg > 0 ? b['_pc_score'] / jobScores[jobB].avg : 1; - return ratioA - ratioB; - }); - - if (criticalPcList.length === 0) { - specMismatchTbody.innerHTML = '사양 주의 자산이 없습니다.'; - } else { - specMismatchTbody.innerHTML = criticalPcList.map((pc: any) => { - const user = pc[ASSET_SCHEMA.CURRENT_USER.key] || '-'; - const dept = pc[ASSET_SCHEMA.CURRENT_DEPT.key] || '-'; - const job = pc[ASSET_SCHEMA.USER_POSITION.key] || '-'; - const status = pc['_spec_status']; - const assetCode = pc.asset_code || '-'; - - const badgeColor = status === '사양 부족' - ? 'background:#FFF1F2; color:#E11D48; border: 1px solid #FDA4AF;' - : 'background:#F0FDF4; color:#16A34A; border: 1px solid #BBF7D0;'; - - return ` - - ${user} - ${dept} (${job}) - - ${status} - - ${assetCode} - - `; - }).join(''); - - // 클릭 시 해당 자산 상세 페이지로 전환 - specMismatchTbody.querySelectorAll('.spec-row').forEach(row => { - row.addEventListener('click', () => { - specMismatchTbody.querySelectorAll('.spec-row').forEach(r => { - (r as HTMLElement).style.backgroundColor = 'transparent'; - }); - (row as HTMLElement).style.backgroundColor = '#EBF2F1'; // 선택 하이라이트 - - const id = (row as HTMLElement).getAttribute('data-id'); - const asset = fullList.find(a => String(a.id) === String(id)); - if (asset) { - updateDetailPanel(asset); - } - }); - row.addEventListener('mouseenter', () => { - if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') { - (row as HTMLElement).style.backgroundColor = '#F8FAFA'; - } - }); - row.addEventListener('mouseleave', () => { - if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') { - (row as HTMLElement).style.backgroundColor = 'transparent'; - } - }); }); } } }; setTimeout(() => { - const selectLoc = document.getElementById('select-loc') as HTMLSelectElement; - const selectDetailLoc = document.getElementById('select-detail-loc') as HTMLSelectElement; - - selectLoc?.addEventListener('change', (e) => { - selectedLocation = (e.target as HTMLSelectElement).value || null; - selectedDetailLocation = null; - updateTableOnly(); - updateFlowLogsSection(); + document.getElementById('select-loc')?.addEventListener('change', (e) => { + selectedLocation = (e.target as HTMLSelectElement).value || null; selectedDetailLocation = null; updateTableOnly(); }); - selectDetailLoc?.addEventListener('change', (e) => { - selectedDetailLocation = (e.target as HTMLSelectElement).value || null; - updateTableOnly(); - updateFlowLogsSection(); + document.getElementById('select-detail-loc')?.addEventListener('change', (e) => { + selectedDetailLocation = (e.target as HTMLSelectElement).value || null; updateTableOnly(); }); updateTableOnly(); - updateFlowLogsSection(); }, 50); }; @@ -957,17 +699,14 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { const table = document.createElement('table'); const thead = document.createElement('thead'); const tbody = document.createElement('tbody'); - table.appendChild(thead); table.appendChild(tbody); - tableWrapper.appendChild(table); + table.appendChild(thead); table.appendChild(tbody); tableWrapper.appendChild(table); const updateTable = () => { let filtered = applyCommonFilters(fullList, currentFilters, config.searchKeys as any[]); if (sortState.key) filtered = dynamicSort(filtered, sortState.key, sortState.direction); - thead.innerHTML = `${config.columns.map(col => `${col.header}`).join('')}`; tbody.innerHTML = filtered.length === 0 ? `${UI_TEXT.MESSAGES.NO_DATA}` : filtered.map(asset => `${config.columns.map(col => `${col.render(asset)}`).join('')}`).join(''); - tbody.querySelectorAll('.asset-row').forEach((tr, idx) => { tr.addEventListener('click', () => config.onRowClick && config.onRowClick(filtered[idx])); }); setupTableSorting(table, sortState, (key, dir) => { sortState = { key, direction: dir }; updateTable(); }); }; @@ -986,35 +725,24 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { toggleWrapper.addEventListener('click', (e) => { const btn = (e.target as HTMLElement).closest('.toggle-btn') as HTMLButtonElement; if (!btn) return; - toggleWrapper.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - (state as any).currentViewMode = btn.getAttribute('data-mode') as 'asset' | 'system'; - switchView(); + const mode = btn.getAttribute('data-mode'); + if (mode === 'location') state.viewMode = 'location'; + else { state.viewMode = 'list'; (state as any).currentViewMode = mode; } + window.dispatchEvent(new Event('refresh-view')); }); - // 필터 바 초기화 renderFilterBar(filterBar, { - ...config.filterOptions, - initialFilters: currentFilters, - onFilterChange: (filters) => { - Object.assign(currentFilters, filters); - updateTable(); - } + ...config.filterOptions, initialFilters: currentFilters, + onFilterChange: (filters) => { Object.assign(currentFilters, filters); updateTable(); } }); - // 셀렉트 박스 채우기 const populateSelect = (selector: string, dataKey: string, initialValue?: string) => { const select = container.querySelector(selector) as HTMLSelectElement; if (select) { - const getVal = (a: any) => dataKey === ASSET_SCHEMA.CURRENT_DEPT.key ? (a[dataKey] || a['현사용부서'] || a['현사용조직']) : a[dataKey]; - const uniqueValues = Array.from(new Set(fullList.map(getVal))).filter(Boolean).sort(); + const uniqueValues = Array.from(new Set(fullList.map(a => a[dataKey]))).filter(Boolean).sort(); uniqueValues.forEach(val => { - const opt = document.createElement('option'); - opt.value = String(val); - opt.textContent = String(val); - if (initialValue && String(val) === initialValue) { - opt.selected = true; - } + const opt = document.createElement('option'); opt.value = String(val); opt.textContent = String(val); + if (initialValue && String(val) === initialValue) opt.selected = true; select.appendChild(opt); }); } @@ -1026,6 +754,5 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { if (config.filterOptions.showType) populateSelect('#filter-type', ASSET_SCHEMA.ASSET_TYPE.key, currentFilters.type); if (config.filterOptions.showStatus) populateSelect('#filter-status', ASSET_SCHEMA.HW_STATUS.key, currentFilters.status); - // 초기 실행 switchView(); } diff --git a/src/views/LocationView.ts b/src/views/LocationView.ts index 9ade3c6..e0ebdeb 100644 --- a/src/views/LocationView.ts +++ b/src/views/LocationView.ts @@ -39,38 +39,48 @@ export async function renderLocationView(container: HTMLElement) { container.innerHTML = `
- -
-
- - + +
+ +
+ + +
-
- -
- - - ${locImages.length > 1 ? ` -
-
- - + +
+
+ + +
+
+ +
+ + + + ${locImages.length > 1 ? ` +
+
+ + +
+ (${currentPage + 1} / ${locImages.length})
- (${currentPage + 1} / ${locImages.length}) + ` : ''}
- ` : ''}
-
- -
+
+ +
${mapPath ? ` @@ -91,27 +101,41 @@ export async function renderLocationView(container: HTMLElement) {
- -
+ +
-

📍 구역을 선택하세요

+

📍 구역을 선택하세요

-
지도에서 자산 위치를 클릭하세요.
+
지도에서 자산 위치를 클릭하세요.
-
-

* 지도 위의 구역을 클릭하면 자산 상세 정보가 표시됩니다.

-
`; - // 이미지 로드 및 윈도우 리사이즈 시 오버레이 크기와 위치를 이미지에 정확히 맞춤 const syncOverlaySize = () => { const img = container.querySelector('#main-map-img') as HTMLImageElement; const overlay = container.querySelector('#box-overlay') as HTMLElement; + const mainContent = container.querySelector('.location-main-content') as HTMLElement; + if (img && overlay && img.complete) { + // 1. 이미지 실제 크기와 가로세로 비율 계산 + const naturalRatio = img.naturalWidth / img.naturalHeight; + + // 2. 비율에 따른 동적 레이아웃 조정 (Adaptive Layout) + if (naturalRatio < 0.85) { + // 세로로 긴 사진: 상세정보를 대폭 넓힘 + mainContent.style.gridTemplateColumns = '0.9fr 1.1fr'; + } else if (naturalRatio < 1.25) { + // 정사각형에 가까운 사진: 균형 배치 + mainContent.style.gridTemplateColumns = '1.2fr 1fr'; + } else { + // 가로로 넓은 사진: 지도 중심 (예전 2:1 비율) + mainContent.style.gridTemplateColumns = '2fr 1fr'; + } + + // 3. 오버레이 크기와 위치 동기화 overlay.style.width = img.clientWidth + 'px'; overlay.style.height = img.clientHeight + 'px'; overlay.style.left = img.offsetLeft + 'px'; @@ -123,7 +147,7 @@ export async function renderLocationView(container: HTMLElement) { if (img) { if (img.complete) { syncOverlaySize(); - setTimeout(syncOverlaySize, 50); // 레이아웃 안정화 대기 + setTimeout(syncOverlaySize, 50); } else { img.onload = syncOverlaySize; } @@ -151,6 +175,20 @@ export async function renderLocationView(container: HTMLElement) { container.querySelector('#btn-prev-page')?.addEventListener('click', () => { currentPage--; render(); }); container.querySelector('#btn-next-page')?.addEventListener('click', () => { currentPage++; render(); }); + // 뷰 모드 전환 이벤트 바인딩 (Unified Logic) + container.querySelectorAll('.toggle-btn').forEach(btn => { + btn.addEventListener('click', () => { + const mode = btn.getAttribute('data-mode'); + if (mode === 'location') { + state.viewMode = 'location'; + } else { + state.viewMode = 'list'; + (state as any).currentViewMode = mode; + } + window.dispatchEvent(new Event('refresh-view')); + }); + }); + container.querySelectorAll('.location-box-point').forEach(box => { box.addEventListener('click', () => { const x = box.getAttribute('data-x'); @@ -177,50 +215,49 @@ export async function renderLocationView(container: HTMLElement) { const title = container.querySelector('#loc-list-title')!; const tableContainer = container.querySelector('#loc-asset-table-container')!; + // 헤더: 자산상세정보 대신 자산번호 + 구분/유형 배치 (CSS Class 사용) title.innerHTML = `
- - 자산 상세 정보 - +
+ ${asset.asset_code || '미부여'} + ${asset.service_type || '운영'} + ${asset.asset_type || 'PC'} +
+
`; - const renderSection = (title: string, fields: { label: string; value: any }[]) => ` + // 섹션 렌더러: 2열 구성 및 폰트 대비 강화 (CSS Class 사용) + const renderSection = (title: string, fields: { label: string; value: any; fullWidth?: boolean }[]) => `
${title}
-
+
${fields.map(f => ` -
${f.label}
-
${f.value || '-'}
+
+
${f.label}
+
${f.value || '-'}
+
`).join('')}
`; const sectionsHTML = [ - renderSection('기본 관리 정보', [ - { label: ASSET_SCHEMA.ASSET_CODE.ui, value: asset.asset_code }, - { label: ASSET_SCHEMA.PURCHASE_CORP.ui, value: asset.purchase_corp }, - { label: ASSET_SCHEMA.CATEGORY.ui, value: asset.category }, - { label: ASSET_SCHEMA.ASSET_TYPE.ui, value: asset.asset_type }, - { label: ASSET_SCHEMA.HW_STATUS.ui, value: asset.hw_status } - ]), renderSection('시스템 사양', [ { label: ASSET_SCHEMA.MODEL_NAME.ui, value: asset.model_name }, { label: ASSET_SCHEMA.OS.ui, value: asset.os }, { label: ASSET_SCHEMA.CPU.ui, value: asset.cpu }, { label: ASSET_SCHEMA.RAM.ui, value: asset.ram }, - { label: ASSET_SCHEMA.GPU.ui, value: asset.gpu } + { label: ASSET_SCHEMA.GPU.ui, value: asset.gpu, fullWidth: true } ]), - renderSection('네트워크 정보', [ + renderSection('네트워크 및 상태', [ { label: ASSET_SCHEMA.IP_ADDR.ui, value: asset.ip_address }, { label: ASSET_SCHEMA.MAC_ADDR.ui, value: asset.mac_address }, + { label: ASSET_SCHEMA.HW_STATUS.ui, value: asset.hw_status }, { label: ASSET_SCHEMA.REMOTE_TOOL.ui, value: asset.remote_tool } ]), - renderSection('구매 및 기타', [ - { label: ASSET_SCHEMA.PURCHASE_DATE.ui, value: asset.purchase_date }, - { label: ASSET_SCHEMA.PURCHASE_AMOUNT.ui, value: asset.purchase_amount ? `${Number(asset.purchase_amount).toLocaleString()}원` : '-' }, - { label: ASSET_SCHEMA.MEMO.ui, value: asset.memo } + renderSection('상세 메모리', [ + { label: ASSET_SCHEMA.MEMO.ui, value: asset.memo, fullWidth: true } ]) ].join(''); @@ -231,8 +268,13 @@ export async function renderLocationView(container: HTMLElement) { `; container.querySelector('#btn-back-to-list')?.addEventListener('click', () => { - title.textContent = `📍 구역을 선택하세요`; - tableContainer.innerHTML = `
지도에서 자산 위치를 클릭하세요.
`; + title.innerHTML = ``; + tableContainer.innerHTML = `
지도에서 자산 위치를 클릭하세요.
`; + }); + + container.querySelector('#btn-back-to-list')?.addEventListener('click', () => { + title.innerHTML = `

📍 구역을 선택하세요

`; + tableContainer.innerHTML = `
지도에서 자산 위치를 클릭하세요.
`; }); container.querySelector('#btn-edit-from-loc')?.addEventListener('click', () => {